MEL: стейт-машина вместо карт обработчиковРЕАЛИЗОВАНО · :core:mel

Область: модули :core:mel + :core:mel-test + :core:mel-compose-material и тулинг :tools:*, артефакты ru.sberbank_sbbol.mel:core / test / compose-material / detekt / graph, версия 0.2.0  ·  Дата: проект 2026-06-13, сверка с кодом 0.2.0 — 2026-10-02  ·  Статус: ядро и тулинг собраны, на MEL работают боевые экраны sbbol; сосуществует с v3/simplemvi
Происхождение: этот документ — причёсанная под факт реализации версия исходной спеки-драфта целевой архитектуры (sbbol mel-design.html). Примеры, привязанные к sbbol (payorder, mass-sign, auth), сохранены как откуда пришли боли; сквозной разобранный пример — пилот qr-sign, живые экраны на MEL — в sbbol (§6). Разделы, помеченные design, спроектированы, но в коде ещё не реализованы — см. §11.

Об имени. MEL = M + EL: Machine + Эль(нар) — рабочее название, автор склеен в одном слове. Второе имя, шуточное: TEMA — The Elnar Machine Architecture, прямой потомок TEA (The Elm Architecture). Отдельное имя — осознанная отстройка от нумерации MVI: MEL не пятая версия зоопарка, а его замена. В коде это пакет ru.sberbank_sbbol.mel.core.mel (разложенный по подпакетам contract/effect/transition/graph/machine/log/runtime/compose), публикуемый под Maven-группой ru.sberbank_sbbol.mel.

Содержание
  1. TL;DR — что собрано
  2. Уроки v3/simplemvi → принципы MEL
  3. Словарь имён → типы в коде
  4. Ядро: контракт и рантайм
  5. Правила контракта экрана
  6. Пилот: экран qr-sign
  7. Подписки, стек состояний и навигация
  8. Тестирование и дисциплина
  9. Сосуществование, публикация, миграция
  10. Минусы и риски
  11. Что реализовано и что осталось открытым

1. TL;DR — что собрано

MEL — цикл Elm/MVU-типа поверх явной стейт-машины, реализованный в модуле :core:mel. Экран описывается одним классом-машиной (MelMachine<S, I, E>, создаётся фабрикой с аргументами экрана — §4.4): sealed-стейты владеют фактами, интенты привязаны к стейтам типами (интент чужого стейта рантайм дропает с логом, не CCE), переходы — чистые быстрые функции (Transition), вся асинхронщина — декларации (Command → интент, Subscription — функция от стейта), one-shot к UI — Effect-данные, исполняемые в композиции против типизированных MelEffectContext.

2. Уроки v3/simplemvi → принципы MEL

Каждый принцип — ответ на конкретную боль зоопарка MVI sbbol (v3: 44 модели, 897 ручных биндингов; SimpleMvi: 241 файл в ~30 фичах):

БольПринцип MEL · как реализовано
897 ручных @Binds @IntoMap @*Key; забытый биндинг — рантайм-краш; неверный ключ — CCE Нет ключей, карт и is. Диспатч — типизированные регистрации on<Стейт, Интент> (GraphBuilder); полнота — melGraphAssert по sealed-листьям + fail-fast в debug.
Интент в «чужом» состоянии: unchecked cast state as S → CCE или тихо-кривое поведение (стейл-клики); SimpleMvi setStateAs<T> — тихий скип Интенты привязаны к стейтам типами. Стейл-интент — определённая политика: drop + лог + onDropped интерсептору (MelViewModel.onUnrouted), а не каст и не тишина.
Гонки параллельных send (Mutex-фикс как заплатка), updateState/errorHandler-гочи, awaitState-цепочки Переходы чистые и быстрые; один последовательный цикл. Асинхронщина — только декларации Command; её результат и её ошибка — обычные интенты. Один consumer канала — Mutex не нужен.
Эффекты теряются (replay=0), отменяются при смене класса стейта; KeepAliveEffect-костыль; «подписки в привязке к списку» достигаются случайно Два разных канала. Effect — буферизованный one-shot к UI (не теряется, собирается в repeatOnLifecycle). Subscription — поток интентов, чей скоуп — функция от стейта: рантайм диффит набор после каждого перехода.
screenState-мешок: факты (showLoader, UseLoader-интент) контрабандой в инструментах; зависшие лоадеры; OnStateChanged-танцы Факты — в стейте, инструменты — в MelEffectContext. Эффекты — данные, не лямбды. Лоадер гаснет по построению: ошибка приходит клеткой (isSigning = false), а не теряется.
DebounceMutator со списком UniqueUtils-строк; дабл-клики как UX-патч Дабл-клик — клетка машины (гейт фактом isSigning), debounce — типизированная декларация config { debounce<Rescan>(500.ms) } — по классу, а не строкой. Параметр типа — II : Any: тип, не связанный с интентами экрана, просто не сработает.
kotlin-reflect на каждом диспатче; eager-создание ~100 обработчиков на модель Рефлексии в горячем пути нет — lookup по Class (findCell). Рефлексия — только в тесте полноты (перечисление sealed-листьев). Создаётся одна машина (+ её секции) на модель.

3. Словарь имён → типы в коде

Правило словаря: знакомые слова сохраняют смысл, новые имена — только у новой семантики.

Код разложен по подпакетам ru.sberbank_sbbol.mel.core.mel.* — слои без циклов: contract · effect · transition · log → graph → machine → runtime → compose.

Правило префикса Mel. Пакет снимает неоднозначность для компилятора; префикс — узнаваемость и отсутствие коллизий у потребителя и в плоском контексте, где пакета не видно (наследование, лог, трейс, автокомплит). Поэтому Mel несут: (1) маркеры контрактов, которые реализует код экрана — MelState, MelIntent, MelEffect, MelFacts, MelPopRequest, capability-маркеры Mel…Effect; (2) публичная поверхность — MelMachine, MelViewModel/MelModel, MelScreen, MelOps, MelStart, MelConfig, MelEffectContext, MelRouter, MelInterceptor, MelRuntime, MelLog, готовые эффекты (терминальный MelExhausted, маркер MelCloseEffect). Без префикса — внутренний словарь DSL, namespace'нутый пакетом: Graph, Cell, ActiveClause, Subscription, ReduceScope, Command, Transition, StackOp, Section, типизированные ключи CommandKey/SubscriptionKey. Третий столбец — пакет/файл в :core:mel.

ИмяЧто этоТип/файл в :core:mel
MEL (шутл. TEMA)Имя подхода: Machine + Эль(нар); потомок TEA. Замена зоопарка, не версия.пакет ru.sberbank_sbbol.mel.core.mel
StateSealed-стейты экрана, владеют фактами. Корень реализует маркер.MelState (маркер, contract/Contracts.kt)
IntentЕдинственный вход машины: от UI, от команд, от подписок. Привязан к стейту типом.MelIntent (маркер)
EffectOne-shot к UI (снек, навигация, диалог). Данные, не лямбды.MelEffect (маркер); сквозные — MelCommonEffect
CommandАсинхронная работа, рождённая переходом и обязанная завершиться интентом. Отмена — набор cancel = setOf(key) перехода; дубль ключа = replace; ключи общие для всей машины, секции их не разделяют (§4.2). Прогресс — commandWithProgress.Command<I> (transition/Command.kt); билдеры — в ReduceScope
SubscriptionПоток интентов с ключом и стейт-скоупом (Elm: Sub). Живёт, пока стейт её скоупа в стеке.Subscription<I> (graph/Graph.kt); скоуп — vararg-клаузы active<…>()
reduce / клеткаФункция перехода одной пары (стейт, интент) — юнит логики.Cell<S, I, E> (graph/Graph.kt)
SectionСвязный набор клеток (+ подписок) с @Inject-зависимостями — переиспользуемый юнит (§6.6).Section<S, I, E> (machine/Section.kt)
Transition / MelStartРезультат клетки: (op, effects, command, cancel); операция стека — StackOp. Рождение машины — узкий MelStart (to + effects/command, без стековой операции и отмен; toTransition()), §4.1.Transition, MelStart, StackOp (transition/Transition.kt)
билдеры (ReduceScope)Словарь переходов next · stay · push · pop · popTo(Class); команды — command / commandWithProgress. Receiver каждой клетки. Машина и секция получают словарь через MelOps, который добавляет рождение startNext и reified-сахар popTo<S>().ReduceScope<S, I, E> (graph/ReduceScope.kt); реализация — MelOps (machine/MelOps.kt)
MelMachine<S, I, E>Определение экрана: start(), graph, config. Создаётся фабрикой с аргументами.machine/MelMachine.kt
MelViewModelРантайм-цикл: дифф подписок, буфер эффектов, стек. Тонкий, общий.runtime/MelViewModel.kt (реализует MelModel)
MelRuntimeРежим строгости и репортер нарушений контракта (failFast, violation()) — не цикл (цикл — MelViewModel). В debug падает, в release логирует.runtime/MelRuntime.kt
MelInterceptorСквозной наблюдатель цикла (переходы, дропы). Только наблюдение; MelDebugInterceptor — из коробки.machine/MelInterceptor.kt
MelBackСистемный Back — высота интеграции (compose), не ядро. Реализует MelPopRequest; без своей клетки ядро делает pop(), пока стек глубже одного, иначе шлёт терминальный MelExhausted. Клетка на back — onAny<MelBack> (on<…> строг к интентам экрана).compose/MelBack.kt
MelPopRequestНейтральный к навигации маркер: неотмаршрутизированный интент с ним ядро обрабатывает само — pop() своего стека, на дне терминальный MelExhausted (MelViewModel.onUnrouted). Ядро знает про свой стек, не про «Back».маркер в contract/Contracts.kt
MelExhaustedТерминальный lifecycle-эффект: попнут последний фрейм стека (MelPopRequest на дне либо pop() со дна). Это MelCommonEffect, не MelCloseEffect: идёт отдельным внутренним каналом, а у интеграции на него свой дефолтный хендлер — рядом с хендлером MelCloseEffect, оба зовут router.close() (compose/MelEffects.kt:66-73).effect/MelCommonEffect.kt
MelScreenЭкранный builder с дефолтами (§4.7): обязателен только content{}. Вставляемый — принимает modifier, кладётся в свой Scaffold/слот.compose/MelScreen.kt
MelContent / MelEffectHostКомпозиционные регистраторы: вью по типу стейта (view/overlay) и исполнение эффектов против MelEffectContext.compose/MelContent.kt, compose/MelEffects.kt
MelEffectContext / MelRouterТипизированный контекст эффект-слоя: база несёт только роутер (MelEffectContext(router)); snackbar host, навигаторы и пр. — подкласс на конкретном экране. Не State.compose/MelEffectContext.kt
melGraphAssert / Transition.assertСтраж полноты графа и assert-DSL клеток.:core:mel-test

4. Ядро: контракт и рантайм

4.1 Контракт (:core:mel, ~два десятка небольших файлов)

Машина и секции делят общую базу билдеров MelOps — клетки строят Transition одним словарём:

// MelMachine.kt — определение экрана (S:MelState, I:MelIntent, E:MelEffect — маркеры контрактов)
abstract class MelMachine<S : MelState, I : MelIntent, E : MelEffect> : MelOps<S, I, E>() {

    /** Рождение машины (§4.4). Аргументы уже в конструкторе. Возвращает узкий тип MelStart
        (val to:S + effects/command), а не Transition: рождение кладёт первый стейт
        в пустой стек, поэтому стековая операция тут лишь одна — startNext(to, …). */
    abstract fun start(): MelStart<S, E>

    /** Граф: ВСЕ связи экрана — типизированные регистрации, без is/смарт-кастов. */
    abstract val graph: Graph<S, I, E>

    /** Декларации машины: debounce<…> и интерсепторы — типами, не строками. */
    open val config: MelConfig = MelConfig.EMPTY
}

// ReduceScope.kt — словарь переходов (interface с реализациями по умолчанию; receiver клетки).
//   MelOps : ReduceScope — база машины и секции; Cell.reduce получает receiver ReduceScope
//   (в рантайме и в Graph.reduce — DefaultReduceScope), так что s,i -> next(…) одинаков
//   в машине, секции и тесте. Ключи команд/подписок — value class CommandKey/SubscriptionKey.
fun next(to: S, vararg effects: E, command: Command<I>? = null, cancel: Set<CommandKey> = emptySet()): Transition<S, E>
fun stay(vararg effects: E, command: Command<I>? = null, cancel: Set<CommandKey> = emptySet()): Transition<S, E>
fun push(to: S, vararg effects: E, command: Command<I>? = null, cancel: Set<CommandKey> = emptySet()): Transition<S, E>
fun pop(vararg effects: E, command: Command<I>? = null, cancel: Set<CommandKey> = emptySet()): Transition<S, E>
fun popTo(target: Class<out S>, vararg effects: E, command: Command<I>? = null, cancel: Set<CommandKey> = emptySet()): Transition<S, E>

/** Асинхронная работа: suspend-блок → финальный интент; ошибка — тоже интент (onError).
    Дубль key = replace. Механики errorHandler нет. */
fun command(key: CommandKey? = null, onError: (Throwable) -> I, block: suspend () -> I): Command<I>

/** Команда с прогрессом: промежуточные эмиссии + ОБЯЗАТЕЛЬНЫЙ финал (возврат блока).
    Отдельное имя, а не перегрузка command — чтобы трейлинг-лямбда без параметров
    не давала неоднозначность перегрузок. */
fun commandWithProgress(key: CommandKey? = null, onError: (Throwable) -> I,
    block: suspend (emit: suspend (I) -> Unit) -> I): Command<I>

// MelOps.kt — база машины и секции (реализует ReduceScope) + узкий билдер рождения и reified-сахар:
//   startNext(to, …): MelStart — отдельный узкий тип старта, НЕ Transition (§4.4).
protected fun startNext(to: S, vararg effects: E, command: Command<I>? = null): MelStart<S, E>
inline fun <reified T : S> popTo(vararg effects: E, command: Command<I>? = null, cancel: Set<CommandKey> = emptySet()): Transition<S, E>

Граф — обычное значение со списком регистраций; собирается DSL-ом graph { … }. Дубли клеток и ключей подписок — ошибка сборки с именами секций (require в GraphBuilder):

class GraphBuilder<S : MelState, I : MelIntent, E : MelEffect> {
    /** Клетка: (стейт, интент или sealed-группа) → обработчик. reduce — на receiver-е
        ReduceScope (билдеры next/stay/… доступны как s,i -> next(…)). on СТРОГ: II : I —
        только свои интенты экрана (чужой интент/опечатка = ошибка компиляции). Сквозные типы
        (MelPopRequest, напр. MelBack), не подклассы контракта экрана, перехватывает onAny (II : MelIntent). */
    inline fun <reified SS : S, reified II : I> on(reduce: ReduceScope<S, I, E>.(SS, II) -> Transition<S, E>)
    inline fun <reified II : MelIntent> onAny(reduce: ReduceScope<S, I, E>.(S, II) -> Transition<S, E>)   // сквозные (Back и т.п.)

    /** Клауза скоупа подписки (§7.1): стейт + типизированный предикат именно на этот стейт.
        Конструируется reified active<SS> { … }; имя 'active' (не 'on') — чтобы не сталкиваться
        с регистрацией клеток. Дефолт предиката — { true }. */
    inline fun <reified SS : S> active(predicate: (SS) -> Boolean = { true }): ActiveClause<S>

    /** Подписка со стейт-скоупом: живёт, пока в стеке есть запись, подходящая под одну из
        клауз (стейт клаузы И её предикат==true). Единый subscribe без арности — скоуп задают
        vararg-клаузы active<…>(); ≥1 клауза ОБЯЗАТЕЛЬНА (пустой скоуп = мёртвая подписка =
        ошибка сборки). onError обязателен для падающих источников (без него —
        debug-краш / бэкофф в release). Дубль ключа — ошибка сборки. */
    fun subscribe(key: SubscriptionKey, vararg clauses: ActiveClause<S>,
        onError: ((Throwable) -> I)? = null, source: () -> Flow<I>)

    fun include(section: Section<S, I, E>)   // секция приносит свои клетки и подписки
}

Результат клетки — Transition(op, effects, command, cancel), где op — sealed StackOp: Next(to) · Push(to) · Pop · PopTo(target) · Stay. Прямой assertEquals упирается в лямбду внутри Command — для тестов клеток есть assert-DSL в :core:mel-test (§8).

Прогрессная команда (форма для загрузки вложения / пакетного подписания «12 из 50»). Правило выбора против подписки: команда конечна и рождена переходом; подписка бесконечна и выведена из стейта:

on<Editing, AttachFile> { s, i -> next(
    s.copy(upload = UploadFacts(i.uri, progress = 0f)),
    command = commandWithProgress(key = CommandKey("upload"), onError = ::UploadFailed) { emit ->
        files.upload(i.uri).collect { p -> emit(UploadProgress(p)) }
        UploadDone(files.result())                               // обязательный финал
    },
)}
on<Editing, CancelUpload> { s, _ -> next(s.copy(upload = null), cancel = setOf(CommandKey("upload"))) }  // отмена — клетка

4.2 Рантайм (MelViewModel, один на все экраны)

Грабля, решённая в реализации (close-vs-cancel). Корутины рантайма (команды, прогресс, подписки, дебаунс) кладут интенты не через suspend-send, а через enqueue = Channel.trySend. На UNLIMITED-канале доставка эквивалентна, но на закрытом канале (после onCleared) trySend возвращает failure, а не бросает ClosedSendChannelException. Это закрывает гонку «джоба завершилась и шлёт результат между close() канала и распространением отмены scope».

4.3 Композиция машин: мост host/child design

Мульти-модельные хосты (в auth 8 экранов в одном компоненте) — реальный кейс. В MEL машины изолированы, поэтому связь — явный мост: хост владеет дочерними машинами и видит их эффекты; ребёнок о хосте не знает, помечает часть эффектов маркером «адресовано выше» (маркер вводится вместе с мостом). Ни маркера, ни рантайма моста (bridge(child){…}) в core пока нет — это первый открытый вопрос пилота мульти-модельного экрана (§11). Пока внутриэкранные подсостояния решаются стеком (§7.2), а межэкранные — эффектом + роутером (§7.3); ни то, ни другое моста не требует.

4.4 Аргументы экрана и старт машины

Аргументы — параметры рождения машины, а не интенты. В v3 экраны слали себе при старте 12 интентов Args.* (New, ExistingById…) каналом событий с гонками cold-start — из-за DI-ограничения ViewModel-карты. В MEL причина не существует: машина создаётся фабрикой, аргументы лежат в конструкторе к моменту start().

// пилот qr-sign: аргумент — типизированные данные (бывший интент FromOperation)
data class QrSignArgs(val operationId: String)

class QrSignMachine(
    private val args: QrSignArgs,                // в примере — руками; в боевых экранах @AssistedInject
    private val interactor: IQrSignInteractor,
    private val docUpdates: IQrDocUpdates,
) : MelMachine<QrSignState, QrSignIntent, QrSignEffect>() {

    // рождение = первый переход: стартовый стейт Loading + команда загрузки от args.
    // startNext(...) возвращает MelStart — узкий тип старта (§4.1), а не Transition.
    override fun start() = startNext(Loading, command = loadCommand())
}

4.5 Compose-слой

Связка «однонаправленная машина + Compose» требует сознательных решений — иначе Compose используется как XML с перерисовкой всего экрана. MEL закладывает их в ядро:

Гранулярная рекомпозиция — из стабильности контрактов

Машина эмитит новый стейт целиком, но Compose скипает поддеревья при структурном равенстве стабильных параметров. Фасетная структура стейта — это и есть skip-механика: смена isReloading не рекомпозирует реквизиты, потому что фасет doc (QrDocFacts) не изменился. Требования: стейты и фасеты — @Immutable-data class'ы, коллекции — immutable, strong skipping (дефолт Kotlin 2.0+). Дисциплину держит detekt-правило MelImmutableFacts (запрет var/мутабельных коллекций/MutableState в классе с маркером MelState/MelFacts).

Анимации переходов — типизированно, в том же стиле регистраций

MelContent — это AnimatedContent под капотом. Дефолт — Crossfade по классу вершины (melCrossfade); точечно — типизированные пары, без is:

transitions {
    between<Loading, Content>(melFadeThrough)   // готовые трансформы в Transitions.kt
    between<Editing, Signing>(melSlideUp)       // смена базовой вершины — выезд снизу
}

Suspend-хендлеры эффектов и превью

Текстовый ввод design — на пилоте не проверен. Правило «буква — Compose, смысл — машина» (TextFieldState — инструмент композиции, машина получает дебаунснутое значение/blur/submit; программная запись — факт с ревизией) спроектировано, но qr-sign формы не имеет, поэтому в коде ещё не отработано. Это явный риск (§10) и кандидат на следующий пилот с формой.

4.6 Process death и rememberSaveable

СлойМеханизмСтатус
АргументыBundle/route восстанавливает система → фабрика отрабатывает повторно (§4.4)по построению
МашинаДефолт — перерождение через start()реализовано
Машина (черновик)opt-in снапшот-эссенция: snapshot { capture<Editing> { … } } + restored как «вторые args»design
КомпозицияSaveableStateHolder по записям стека (ключ — «индекс + класс» записи) в MelContentреализовано

Дефолт честный: process death = перерождение. start() детерминирован, args восстановлены — машина заново грузится. Это сегодняшнее поведение всего зоопарка, и для списков/деталок оно правильное. MelContent оборачивает каждую запись стека в SaveableStateHolder с ключом "$index:${класс}" (compose/MelContent.kt:61): next того же класса ключ сохраняет, так что скролл и прочее переживают обновление фактов; push не убивает remember-состояние подложки, а ключи ушедших из стека записей чистятся из holder'а. Ограничение такого ключа: новая запись того же класса на той же позиции получает тот же ключ, поэтому оверлей, переоткрытый сразу после закрытия, может унаследовать UI-мелочь закрытого. Opt-in снапшот черновика (snapshot-блок, capture-в-момент-save, restored как вторые args) спроектирован, но не реализован — нужен экрану вроде payorder с набитой полформой; в пилоте qr-sign черновика нет.

4.7 DX-пакет: дефолты как builder

Архитектура масштабируется вниз хуже, чем вверх. Лечится пакетом дефолтов, оформленных единым builder-ом MelScreen: всё опционально, кроме контента; дефолт действует, пока блок не объявлен; переопределение — видимая строчка в ревью.

@Composable
fun QrSignScreen(model: MelModel<QrSignState, QrSignIntent, QrSignEffect>, router: MelRouter) {
    val snackbar = remember { SnackbarHostState() }            // snackbar — инструмент ЭТОГО экрана, не core
    Box(Modifier.fillMaxSize()) {
        // MelSnackContext (router + snackbar-host) — из :core:mel-compose-material (§5 п.1);
        // лямбда получает дефолтный контекст, из него берётся роутер
        MelScreen(model, router = router, context = { MelSnackContext(it.router, snackbar) }) {
            content {                                          // единственный обязательный блок
                view<Loading> { QrSignShimmer() }
                view<Content> { s -> QrSignContentView(s, model::send) }
                overlay<Confirm> { s -> QrSignConfirmView(s, model::send) }   // оверлей поверх живого Content
                view<Stub> { s -> QrSignStubView(s, model::send) }
            }
            effects {                                          // закрытие — у дефолта; снек/навигация — тут
                on<QrSignEffect.ShowSnack> { e, context -> context.snackbar.showSnackbar(e.text) }
                on<QrSignEffect.NavigateToPayment> { e, context -> /* роутер */ }
            }
            transitions { between<Loading, Content>(melFadeThrough) }  // точечно поверх Crossfade
        }
        SnackbarHost(snackbar, Modifier.align(Alignment.BottomCenter))
    }
}

// модель — фабрикой из машины, без per-screen MelViewModel-подкласса (§6.5). Новый key = новая
// машина (новый start()). Per-screen VM остаётся допустимым, но не обязателен.
val model = melViewModel(key = "qr-sign-$session") { QrSignMachine(args, interactor, docUpdates) }

// тривиальный экран — это ВЕСЬ его UI-код (паритет с SimpleMvi по церемонии):
@Composable
fun TechBreakScreen(model: MelModel<…>) = MelScreen(model) {
    content { view<Content> { s -> TechBreakContent(s, model::send) } }
}
Дефолт билдераЧто даёт без единой строчки
contextMelEffectContext(router) из core: только роутер (без явного router — NoopMelRouter, чей close() = violation). Свои инструменты (snackbar host, навигаторы, аналитика) — подкласс MelEffectContext(router) и перегруз MelScreen(…, context = { XContext(it.router, …) }): лямбда получает дефолтный контекст и берёт из него роутер (в sbbol так собраны контексты боевых экранов, напр. AccountRestrictionsContext(it.router, navigator, analytics); BankSearchPullerScreen обходится дефолтом — MelScreen(model = model) { … }). Готовое material-расширение — MelSnackContext(router, snackbar) в отдельном модуле :core:mel-compose-material (material3 не утекает в ядро, §5 п.1)
effectsДва дефолтных хендлера из core (compose/MelEffects.kt:66-73): на маркер MelCloseEffect и на терминальный MelExhausted, оба зовут router.close(). Эффект закрытия экрана не требует строчки в effects{} (так Close : LightBankChargesPullerEffect, MelCloseEffect в sbbol). Регистрация специфичнее переопределяет дефолт (легально)
transitionsCrossfade по классу базовой вершины; оверлеи — фиксированный выезд снизу + fade (§4.5)
BackBackHandler(enabled = stack.size > 1 || handlesBack) → model.pop() → MelBack: глубже одного без своей клетки — pop() стека ядра. На КОРНЕ включается, когда MelModel.handlesBack = true, то есть у машины есть клетка на MelBack для текущей вершины (onAny<MelBack>, как в SelfRequisitesMachine) — машинно-владеемый root-back; иначе на корне Back отдаётся системе (NavHost/фрагменты) (§7.2, §7.3)
СохранениеSaveableStateHolder по ключам «индекс + класс» записей стека (§4.6)

5. Правила контракта экрана

  1. Факты — в стейте, инструменты — в MelEffectContext. Тест: «есть equals по значению / нужно клетке / проверяется в юнит-тесте» → стейт; «объект с методами show()/dismiss()» → контекст. Никаких MutableState-флагов в контексте (detekt MelNoMutableEffectContext); никаких инструментов (TextFieldState, пейджеры) в стейте (detekt MelNoInstrumentsInState). Контекст эффект-слоя получает только эффект-хендлер; вью инструментов не видят (вью = чистая функция стейта, state→intent). Базовый MelEffectContext несёт только роутер; готовое material-расширение со снек-хостом — MelSnackContext(router, snackbar) в отдельном модуле :core:mel-compose-material (чтобы material3 не утекал в ядро :core:mel); на экране context = { MelSnackContext(it.router, snackbar) }, а хендлеры снека/ошибки идут против context.snackbar или context.showSnack(…) (§4.7).
  2. Стейт = «перезапустить жизненный цикл», поле = «факт внутри него». Смена стейта пересоздаёт вью и пересчитывает подписки; то, что должно пережить (рефреш-шиммер, лоадер), — поля текущего стейта (isReloading, isSigning).
  3. Интенты — sealed-группами на стейт (ContentIntent : QrSignIntent), внутри — подгруппы по секциям. Сквозные (Back) — сквозной тип интеграции (MelBack : MelPopRequest) через onAny/дефолт.
  4. Эффекты — данные. Лямбды с захватом инструментов (v3 UseSnackbar { … }) запрещены (detekt MelEffectsAreData): исполнение — только в композиции, регистрациями on<Эффект> в effects{}.
  5. Результаты и ошибки команд — интенты с именами-фактами (Loaded, SignFailed) — они такие же клетки машины, как клики.
  6. Никаких is/as по стейтам, интентам и эффектам в коде экранов (detekt MelNoTypeChecks). Любая связь — типизированная регистрация. Цена зафиксирована честно: exhaustiveness переезжает от компилятора к тесту (melGraphAssert) и debug-fail-fast; взамен — один стиль диспатча на весь фреймворк и отсутствие смарт-каст-простыней.

6. Пилот: экран qr-sign

Пилот — разобранный пример, а не часть поставки MEL: порт реального sbbol-экрана подписания по QR (payorder/.../qr_sign: QRSignMviModel + QRSignComponent с ~9 редьюсерами и Dagger-картой биндингов, DebounceMutator, эффекты ShowLoader/ShowShimmering). Здесь — один класс-машина + секция, без is/as и без карт. Экран средней сложности специально: реальная подписка, оверлей-пуллер и фоновая маршрутизация — чтобы показать ядро в бою. Фрагменты ниже иллюстративны и написаны против API 0.2.0.

Живые экраны на MEL — в sbbol, у каждого тест машины с melGraphAssert: feature/account/impl/…/ui/mel/restrictions (ограничения по счёту: вкладки, push деталки), feature/charges/impl/…/ui/mel/charges_puller (пуллер штрафов, эффект закрытия по маркеру), feature/creation/impl/…/ui/mel/puller (пуллер создания документа, push источников распознавания), feature/counterparties/impl/…/ui/mel/bank_search_puller (поиск банка с debounce), feature/payorder/impl/…/ui/mel/selfrequisites (форма реквизитов: стейт-одиночка, root-back через onAny<MelBack>). Секций и подписок в них пока нет — эти механики показывает пилот.

6.1 Стейты: владеют фактами

sealed interface QrSignState : MelState {

    data object Loading : QrSignState                  // стартовый шиммер из start(); args уже в машине

    data class Content(                                 // документ показан
        val doc: QrDocFacts,                           // фасет фактов (QrDocFacts : MelFacts)
        val isReloading: Boolean = false,              // бывший ShowShimmeringEffect — факт, не эффект
    ) : QrSignState

    data class Confirm(                                 // ОВЕРЛЕЙ-пуллер поверх Content (push, §7.2)
        val doc: QrDocFacts,
        val isSigning: Boolean = false,                // бывший ShowLoaderEffect — факт; гаснет по построению
    ) : QrSignState

    data class Stub(val message: String) : QrSignState // тупик с кнопкой повтора
}

6.2 Интенты: привязаны к стейтам, sealed-группами

sealed interface QrSignIntent : MelIntent {
    sealed interface LoadingIntent : QrSignIntent {
        data class Loaded(val doc: QrDocFacts) : LoadingIntent
        data class LoadFailed(val message: String) : LoadingIntent
    }
    sealed interface ContentIntent : QrSignIntent {
        data object SignClick : ContentIntent           // открыть пуллер
        data object Rescan : ContentIntent              // перечитать (дебаунсится в config)
        data class Rescanned(val doc: QrDocFacts) : ContentIntent
        data class RescanFailed(val message: String) : ContentIntent
        data class DocUpdated(val doc: QrDocFacts) : ContentIntent    // эмиссия подписки docUpdates (фоновая)
        data class UpdatesBroken(val message: String) : ContentIntent // onError подписки
        data object GoToPayment : ContentIntent
        data object CloseClick : ContentIntent
    }
    sealed interface ConfirmIntent : QrSignIntent {
        data object ConfirmSign : ConfirmIntent
        data object CancelSign : ConfirmIntent
        data class Signed(val doc: QrDocFacts) : ConfirmIntent
        data class SignFailed(val message: String) : ConfirmIntent
    }
    sealed interface StubIntent : QrSignIntent { data object Retry : StubIntent }
}
// Back в контракт НЕ входит: интеграционный MelBack : MelPopRequest, дефолт pop()/MelExhausted

6.3 Машина: тонкий граф + секция

override fun start() = startNext(Loading, command = loadCommand())            // MelStart, не Transition (§4.1)

override val graph = graph<QrSignState, QrSignIntent, QrSignEffect> {
    on<Loading, Loaded>     { _, i -> next(Content(i.doc)) }
    on<Loading, LoadFailed> { _, i -> next(Stub(i.message)) }
    on<Stub, Retry>         { _, _ -> next(Loading, command = loadCommand()) }  // повтор = путь рождения

    on<Content, SignClick> { s, _ -> push(Confirm(s.doc)) }                     // пуллер: Content жив в стеке
    on<Content, Rescan>    { s, _ -> next(s.copy(isReloading = true),
        command = command(key = CommandKey("load"), onError = { RescanFailed(it.userMessage()) }) {
            Rescanned(interactor.getDocument(args.operationId))
        }) }
    on<Content, Rescanned>   { s, i -> next(s.copy(doc = i.doc, isReloading = false)) }
    on<Content, RescanFailed>{ s, i -> next(s.copy(isReloading = false), ShowError(i.message)) }
    on<Content, DocUpdated>  { s, i -> next(s.copy(doc = i.doc)) }              // фоновая маршрутизация (§7.2)
    on<Content, UpdatesBroken>{ s, i -> stay(ShowError(i.message)) }
    on<Content, GoToPayment> { s, _ -> stay(NavigateToPayment(s.doc.id)) }     // навигация — данные (§7.3)
    on<Content, CloseClick>  { _, _ -> stay(Close) }

    include(SignSection(interactor))                                          // клетки Confirm — в секции

    // подписка со скоупом из ДВУХ стейтов (§7.1): обновления нужны и в Content, и под пуллером Confirm,
    // поэтому push(Confirm) её не рвёт. Скоуп — vararg-клаузы active<…>() (per-state DSL).
    subscribe(SubscriptionKey("docUpdates"), active<Content>(), active<Confirm>(),
        onError = { UpdatesBroken(it.userMessage()) }) {
        docUpdates.flow(args.operationId).map(ContentIntent::DocUpdated)
    }
}

override val config = config { debounce<ContentIntent.Rescan>(500.milliseconds) }  // типом, не строкой

6.4 Секция — клетки одной зоны ответственности

class SignSection(private val interactor: IQrSignInteractor) :
    Section<QrSignState, QrSignIntent, QrSignEffect>() {

    override fun GraphBuilder<…>.register() {
        on<Confirm, ConfirmSign> { s, i -> confirmSign(s, i) }
        on<Confirm, CancelSign> { _, _ -> pop() }
        on<Confirm, Signed>     { _, _ -> pop(ShowSnack("Документ подписан")) }  // этап завершён — закрываем пуллер;
                                                                              // подписанный документ долетит до Content подпиской
        on<Confirm, SignFailed> { s, i -> next(s.copy(isSigning = false), ShowError(i.message)) }
    }

    private fun confirmSign(state: Confirm, intent: ConfirmSign) =
        if (state.isSigning) stay()                          // дабл-клик — клетка машины, не DebounceMutator
        else next(
            state.copy(isSigning = true),                    // бывший ShowLoaderEffect — факт
            command = command(key = CommandKey("sign"), onError = { SignFailed(it.userMessage()) }) {
                Signed(interactor.sign(state.doc))
            },
        )
}

Клетка ConfirmSign вынесена в метод, чтобы показать градиент «инлайн → метод → секция» (§6.6). Метод видит словарь переходов, потому что Section наследует MelOps : ReduceScope. Экстрактор графа разворачивает такой хвостовой вызов функции машины/секции в её переходы, включая ветки if (§8.2).

Что произошло с болями экрана: зависший лоадер невозможен (ошибка — это клетка SignFailed, обязанная вернуть стейт без isSigning); дабл-клик ConfirmSign — явный stay() вместо строкового debounce-списка; «интент подписания во время редактирования» структурно немаршрутизируем (вью реквизитов физически не пошлёт ConfirmIntent); state as S-кастов нет — клетки секции типизированы Confirm регистрацией.

6.5 Что показывает пилот: фоновая маршрутизация

Главная демонстрация — интент фоновой подписки долетает к записи под вершиной стека. Сценарий подписи:

[Content]            подписка docUpdates запущена (скоуп Content+Confirm)
[Content, Confirm]   SignClick → push: Content жив, подписка не дёргалась
                     ConfirmSign → isSigning=true + команда sign
                     ... interactor.sign() эмитит подписанный документ в шину docUpdates ДО возврата ...
[Content, Confirm]   Signed (финал команды) → pop(ShowSnack) — закрываем пуллер
[Content]            на записи Content уже / вот-вот применится DocUpdated(signed) фоновой подпиской

Порядок Signed / DocUpdated не важен: pop() возвращает к живому Content, чьи факты актуализирует фоновый интент (клетка on<Content, DocUpdated> применяется к записи под пуллером — StackOp.Next по своему индексу, §7.2), а не к замороженному снапшоту. Итог детерминирован — Content(signed). Сама механика покрыта рантайм-тестом ядра (MelViewModelSubscriptionTest: подписка живёт при push, фоновый интент актуализирует свою запись в глубине стека).

Создание модели — фабрикой melViewModel. Рантайм-фасад экрана — MelModel<S, I, E> (stack/state/handlesBack/effects + send(I)/pop()), его реализует общий MelViewModel. Ни пилот, ни боевые экраны sbbol не пишут per-screen подкласс ViewModel: @Composable melViewModel(key) { machine } (runtime/MelViewModelFactory.kt) поднимает модель прямо из машины через androidx-viewModel; новый key = новая машина (новый start(), как key("qr-sign-$session") в примере). Без явного key дефолт выводится из типов S/I/E (иначе viewModel ключует по стёртому MelViewModel — одинаковому для всех экранов, и два melViewModel{} под одним стором вернули бы один инстанс); два инстанса ОДНОГО экрана различай явным ключом. Per-screen MelViewModel-подкласс остаётся допустимым (когда нужен свой конструктор/DI), но обязательным не является.

6.6 Секции: доктрина и переиспользование

Секция — составная часть графа: связный набор клеток (+ подписок), оформленный обычным @Inject-классом (Section<S, I, E>, наследник MelOps). Ось секции — зона ответственности, а не стейт. В пилоте SignSection накрывает Confirm; на большом экране у одного Editing было бы несколько секций (поля, получатель, действия). Машина делает include(section) — и всё.

Доктрина — три правила

  1. Секция владеет клетками, не стейтом. Форма стейта — собственность контракта экрана; секции читают и copy()-ят. Дубль клетки (стейт, интент) между секциями — ошибка сборки графа, сообщение называет обе секции.
  2. Секции общаются через факты стейта, не через интенты друг другу. «Послать интент соседней секции» — возрождение v3-шных awaitState-цепочек; общая логика выносится в обычные функции, вызываемые из обеих клеток.
  3. Подписки секции — её собственные: питают её же клетки. Коллизия ключей подписок между секциями — та же ошибка сборки. Ключи команд, напротив, секциями не разделяются — они общие для всей машины (§4.2): одинаковый CommandKey в двух секциях — один слот, и cancel одной секции отменит команду другой. Чтобы секции не управляли друг другом в обход правила 2, ключи команд у секций должны быть различимы.

Когда секция не нужна: это инструмент организации, не обязательный слой. В пилоте Content-зона — клетки инлайн в машине, секция только у подписания. Эвристика: секция появляется, когда у зоны есть свои зависимости, больше ~5–7 клеток или нужна переиспользуемость (в sbbol SignPullerModule жил в 5 компонентах → одна секция, include в N машин). Градиент: инлайн-клетки → приватные методы машины → секция.

Нюанс переиспользуемых секций. Sealed-иерархия интентов экрана не может включать чужие подклассы (sealed требует тот же модуль), поэтому сквозные типы (ядровый MelPopRequest, его интеграционный наследник MelBack) — не-sealed, и переиспользуемая секция регистрирует их через onAny. Следствие: сквозные интенты не участвуют в проверке полноты по sealed-листьям — там их покрытие за дефолтным поведением core. Сигнатуры это отражают: on<SS, II> строг (II : I — чужой интент или опечатка не скомпилируются), а onAny принимает любой II : MelIntent.

7. Подписки, стек состояний и навигация

7.1 Лестница времени жизни подписок

Дифф идёт по ключам, а не по классам стейтов: ключ, оставшийся в наборе после перехода, означает «не трогать». Подписка объявляется один раз со стейт-скоупом (повторный ключ — ошибка сборки). Ступени:

ТребованиеДекларацияПоведение
Подписку нельзя дёргать вообще (рефреш) Не уходить из стейта: Content(isReloading = true) Набор не меняется — подписка не замечает рефреша
Пережить промежуточный/оверлейный стейт Скоуп из нескольких клауз: subscribe(SubscriptionKey("docUpdates"), active<Content>(), active<Confirm>()) push(Confirm) — Content в стеке, подписка живёт непрерывно (ровно случай пилота)
Перезапуститься («начать заново») Скоуп — одна клауза: subscribe(SubscriptionKey(…), active<Content>()) Отмена на выходе, рестарт на входе — семантика, не баг. События за окно отсутствия теряются по построению
Приостановиться по факту (бывший императивный cancel(X)) Предикат в клаузе: subscribe(SubscriptionKey("refresh"), active<List> { !it.isSigning }) Отмена при isSigning, рестарт при возврате факта (именно рестарт, не пауза)

7.2 Стек состояний

Линейная машина упирается в контекстно-слепые промежуточные стейты. Ответ — стек состояний (StackOp в transition/Transition.kt, реализован в MelViewModel.applyOp):

next(to)          // replace вершины: стейт ЗАВЕРШЁН (поведение линейной машины)
push(to)          // поверх: нижний стейт ЖИВ — факты сохранены, подписки не трогаются
pop()             // вернуться к живому стейту, как он был (актуализированному фоновыми интентами)
popTo<Content>()  // аналог v3 backTo

Семантика (важнее API):

Доктрина выбора (симметрична «стейт vs поле»): push — «состояние живо, я вернусь» (факты + подписки сохраняются); next — «состояние завершено» (подписки честно умирают). Лоадеру в стеке делать нечего: немая вершина без клеток ломает маршрутизацию и попадает под Back — лоадер при операции это факт (isSigning), а не запись стека. Критерий записи стека — наличие собственного жизненного цикла (интенты, флоу, подписки), как у пуллера подписания.

Принцип: машина не знает о существовании других экранов. Наружу она отдаёт данные (Effect), внутрь получает данные (Intent). Межэкранный слой (фрагменты, NavController, IMainRouter, диплинки) MEL не трогает — он живёт за инструментами MelEffectContext: базовый MelRouter умеет только close(), переходы — инструменты подкласса контекста экрана. MEL стандартизирует только край экрана.

УровеньМеханизмВ пилоте
Внутри экрана (подсостояния, пуллеры)Стек машины: push/pop/next (§7.2)Content → пуллер Confirm
Внутри мульти-модельного хостаМост host/child design—
Между экранамиEffect-данные → роутер в контексте; обратно — Intent через подпискуNavigateToPayment → роутер
// эффекты навигации — данные, без роутера в машине
on<Content, GoToPayment> { s, _ -> stay(QrSignEffect.NavigateToPayment(s.doc.id)) }

// исполнение — в композиции; навигатор — инструмент из контекста экрана (подкласс MelEffectContext)
effects { on<NavigateToPayment> { e, context -> context.payments.open(e.docId) } }

// ВОЗВРАТ результата — это вход, значит подписка → интент (как docUpdates в пилоте)
subscribe(SubscriptionKey("pickerResult"), active<Content>()) { savedStateHandle.getStateFlow(KEY, null).filterNotNull().map(::Selected) }

8. Тестирование и дисциплина

Знание о машине собрано в значение Graph (список регистраций) — его инспектируют рантайм, тесты и debug-fail-fast. Это избавляет MEL от собственного KSP: генерировать нечего (карт и ключей нет), а тела функций KSP всё равно не видит.

// пример (qr-sign): клетка через тот же lookup, что в рантайме (Graph.reduce), + assert-DSL (TransitionAssert)
machine.graph.reduce(Confirm(doc), ConfirmSign)!!.assert {
    to<Confirm> { it.isSigning }
    command("sign")
}

// sbbol, LightBankChargesPullerMachineTest: страж полноты с сознательным исключением
@Test fun graph() {
    melGraphAssert(machine) { terminal<Empty>() }   // у пустого списка выбирать нечего
}

На боевых экранах sbbol тест машины — melGraphAssert плюс табличные кейсы клеток через Transition.assert (AccountRestrictionsMachineTest, BankSearchPullerMachineTest, CreationPullerMachineTest, LightBankChargesPullerMachineTest, SelfRequisitesMachineTest). Сам MEL тестируется в модулях рядом с кодом: :core:mel (рантайм: цикл, специфичность клеток, команды, подписки, эффекты, стек и back, вынесенные клетки), :core:mel-test (граф-ассерт и DSL), :tools:detekt-rules (по тесту на правило), :tools:mel-graph (экстракция, формы переходов, граф-линтер), :tools:mel-idea-inspections-test (инспекции IDE на лёгкой платформе). Рантайм-тесты идут на StandardTestDispatcher из :core:coroutine-test — тестовая обвязка, не публикуется.

8.1 Detekt-набор mel-* (:tools:detekt-rules)

26 правил дисциплины в 5 группах (рулсет mel); потребитель подключает их как detekt-плагин ru.sberbank_sbbol.mel:detekt:0.2.0 — в sbbol это detektPlugins libs.mel.detekt в devops/detekt/detekt.gradle. Все правила — PSI, без type resolution: IDE-плагин detekt не поддерживает type resolution для подсветки на лету (известное ограничение), поэтому от TR отказались — так все правила работают и в Studio на лету, и в Gradle-таске. Контракты детектятся по маркерам (MelState/MelIntent/MelEffect/MelFacts из contract/Contracts.kt): хелпер идёт BFS по супертипам в пределах файла в поисках маркера — легаси *State/*Effect без маркера не трогается. Машина и секция — по прямому супертипу MelMachine/Section; клетка — по лямбде on/onAny внутри машины.

ГруппаЧто бережётПравила
Базовая восьмёркаФундамент модели: без корутин в машине, контракт-данные, без is/as, рэтчет миграцииMelNoCoroutinesInMachine, MelSubscribeOnError, MelEffectsAreData, MelNoMutableEffectContext, MelImmutableFacts, MelNoInstrumentsInState, MelNoTypeChecks, MelMigrationRatchet
Контракты-данныеСтейт/факты/интент/эффект остаются значениями со структурным равенством, без лямбд, платформы и бизнес-логикиMelDataContractLeaf, MelNoLambdaInContract, MelNoPlatformTypesInContract, MelNoBehaviorInContract, MelSealedContractRoot
Тело машиныМашина stateless, граф — только через DSL, ошибка команды маппится в интентMelStatelessMachine, MelNoSendInReduce, MelNoRawFrameworkConstructor, MelCommandOnErrorMustReturnIntent
Чистота reduceКлетка — чистый синхронный шаг: без бросков, мутаций, побочных эффектов, недетерминизма и блокировокMelNoThrowInReduce, MelNoMutationInReduce, MelNoSideEffectsInReduce, MelNoNonDeterminismInReduce, MelNoBlockingInReduce
Async / эффект-слойКоманды и подписки не блокируют loop, источник холодный, канал эффектов — у одного потребителя, закрытие — через контрактMelBlockingInCommandOrSubscription, MelHotFlowSubscriptionSource, MelNoManualEffectCollection, MelNoRouterCloseEffect

Полный каталог с колонками «срабатывает на» / «зачем», уровнями (Defect/Style), параметрами правил и оговорками — в MEL Detekt. Здесь намеренно только группы: дублировать таблицу из 26 строк в двух документах — прямой путь к расхождению.

8.2 Инструменты графа реализовано

Собрано — модуль :tools:mel-graph + IDE-плагин :tools:mel-idea-plugin (версия 0.1.3, id ru.sberbank_sbbol.mel.idea, Android Studio от сборки 253.32098.37): статическая экстракция графа из PSI (MelGraphExtractor / headless-MelPsiWalker для CI); тул-окно «MEL» — каталог экранов и виды «Граф состояний» (собственный Java2D-рендер поверх раскладки ELK, без JCEF), «Превью» (рендер @Preview на Layoutlib самой студии), «Проблемы»; навигация в исходник; экспорт PNG и копирование Mermaid. Карта экранов собрана, но выключена флагом mapEnabled = false (MelToolWindowFactory.kt:68). Граф несёт overlay-стейты, ключи команд и их результаты, дебаунс, подписки. Что и как показывает плагин — в MEL Plugin; здесь — только то, что важно для спеки.

Формы переходов, которые понимает экстрактор: стейт-одиночка — data class без sealed-иерархии становится единственным состоянием и целью всех next/push машины (так устроен SelfRequisitesState в sbbol); ветвление if/when внутри цели next(…) даёт по ребру на ветку; хвостовой вызов функции, объявленной в том же классе машины или секции (openDocument(i.document) в CreationPullerMachine), в том числе в ветке if, разворачивается в переходы её тела. Всё это закреплено TransitionShapesTest. Сверки статического графа с рантаймом (golden) нет: экстракцию проверяют статические тесты :tools:mel-graph, а полноту настоящего графа — melGraphAssert.

Инспекции в редакторе собраны: в plugin.xml зарегистрировано 26 localInspection — по одной на каждое правило detekt-набора (§8.1), так что дисциплина держится не только на Gradle-таске, но и на лету в редакторе. Правила и инспекции компилируют один и тот же исходник PSI-хелперов (tools/detekt-rules/src/psi: MelPsi.kt, MelDefaults), поэтому эвристики распознавания контрактов и машины у них общие и не расходятся. Тесты инспекций — отдельный модуль :tools:mel-idea-inspections-test на лёгкой тест-платформе. Quick-fix есть у двух инспекций, где механически безопасный фикс существует: MelSubscribeOnError (добавить onError = {…}) и MelDataContractLeaf (сделать data class); остальные 24 — только подсветка.

Граф-линтер собран: lintScreen в :tools:mel-graph — 13 структурных проверок поверх извлечённого графа: недостижимые состояния, тупики без терминального маркера, неразрешённые цели переходов, переходы в несуществующий стейт, отсутствие старта, результаты команд без клетки, подписки без onError / на горячем источнике / на несуществующий стейт, эмиты подписок без клетки, оверлеи без push, next в оверлей вместо push, интент с несколькими клетками. Ложные срабатывания по onError гасит классификация источника (горячий / .catch выше по цепочке / непадучий / app-lifetime шина). Известный ложный сигнал: next оверлея в самого себя (обновление его фактов) линтер тоже считает «next в оверлей» (Lint.kt:160 не исключает самопереход).

Ещё не сделано design: quick-fix у остальных 24 инспекций; включение карты экранов. Пределы экстрактора: хелпер разворачивается, только если его вызов — значение клетки, а функция объявлена в том же классе; переход, собранный в локальную переменную или во внешней функции, в граф не попадает. Дверь к FIR-чекеру компилятора (видит тела регистраций) сознательно закрыта из-за цены поддержки — открывать, только если тест-таймовая полнота начнёт пропускать реальные баги.

9. Сосуществование, публикация, миграция

9.1 Гайд переноса v3 → MEL

Главный вопрос к каждому v3-редьюсеру: «чем он был на самом деле?» В v3 редьюсер — единственный юнит, поэтому в нём смешаны вычисления, оркестрация IO и обработка ошибок. Таблица разложения:

Что в v3-редьюсереКак узнатьКуда в MEL
Sync-вычисление, валидация, форматированиеНет suspend-вызововТело клетки — переносится почти дословно
Suspend-вызов интерактораinteractor.xxx() в reducecommand(onError) { ResultIntent(…) } + клетка результата
Цепочка awaitState(MapX(…)) as XStateИнтент Map* шлётся только из редьюсеровТипизированная функция-маппер; интент умирает, касты исчезают
Лоадер-каскад (UseLoader/ShowLoader/HideLoader)Интенты/эффекты без бизнес-смыслаФакт isSaving в стейте; вью рисует из факта
cancel(ListenRefresh) посреди reduceИмперативная отмена подпискипредикат клаузы active<S> { … } подписки (§7.1) или клетка-гард
send(X, policy = OnStateChanged)Отложенная отправка через очередьНе нужен: эффекты буферизованы, подписки стартуют от стейта; «после отрисовки» — LaunchedEffect во вью
Интенты Args.* при стартеШлются один раз из обвязкиПараметры конструктора машины + start() (§4.4)
errorHandler / try-catch—Клетка error-интента: факт в стейт + Effect; страховки не нужны
Гард if (state !is X) return state—Не нужен: клетка типизирована стейтом регистрацией
Эффект-лямбда (UseSnackbar { … })Лямбда захватывает хэндлыEffect-данные; исполнение — в effects{}

Правило инвентаризации интентов: интент в MEL — только реальное событие (действие пользователя, результат команды, эмиссия подписки). v3-интент, который посылался только из других редьюсеров (Map*, UseLoader), — это вызов функции, замаскированный под интент: при переносе он не становится клеткой, а растворяется в функциях. В пилоте qr-sign это уже видно: бывшие ShowShimmering/ShowLoader стали фактами isReloading/isSigning, FromOperation — аргументом конструктора.

9.2 Гайд переноса SimpleMvi → MEL

SimpleMvi (241 файл в ~30 фичах) — концептуальный младший брат MEL, перенос идейно проще, чем v3: та же триада <Intent, State, Effect> (интенты уже sealed), последовательная обработка, даже буферизованный эффект уже изобретён (setBufferedEffect).

SimpleMviMEL
Contract.Intent/State/EffectПереносится; Intent уже sealed
setInitialState() + init {}start(); args — в конструктор (§4.4)
handleIntent + whenКлетки on<Стейт, Интент>. Одностейтовые экраны — машина с одним стейтом
setStateAs<T> — тихий скипСтейт-типизация клетки: drop + лог вместо тишины
launch { interactor…; setState {…} }command(onError) { ResultIntent } + клетка результата
init { bus.collect { … } } — навсегдаsubscribe(key, active<Стейт>(), onError) с осознанным скоупом
setEffect (теряется) vs setBufferedEffectОдин Effect-канал, всегда буфер — различие исчезает
Навигация императивно из VM, мимо Effect-каналаРоутер → MelEffectContext, клики → Effect-данные — главная работа миграции
PagingPager-инструмент в стейтеCore-паттерн пейджинга design (§11) — единственное, где нужен новый паттерн, а не перенос

10. Минусы и риски

РискМитигация · статус
Новый фреймворк, а не рефакторинг: перенос экрана — ручная переукладка логикиMEL только для новых и переписываемых экранов; гайд переноса (§9); v3/simplemvi живут сколько нужно. Пилот доказал перенос среднего экрана
Ещё одна MVI в кодовой базе — риск зоопаркаDefault для нового кода + MelMigrationRatchet (warning → error после стабилизации); v1/v2 — отдельный трек на выпил
Правило «без is»: exhaustiveness не у компилятораmelGraphAssert (sealed-листья ↔ клетки) + fail-fast в debug + detekt MelNoTypeChecks — на CI практически эквивалент компилятора. FIR-чекер — дверь на будущее (§8.2)
Дисциплина «эффекты — данные», «факты не в контексте» держится на конвенции26 detekt-правил (§8.1) + 26 парных IDE-инспекций (§8.2), работают и в редакторе, и на CI — реализовано
Текстовый ввод через машину — лаг эха и прыжки курсора (цикл на фоне)Правило §4.5 «буква — Compose, смысл — машина» спроектировано, но на пилоте не проверено (qr-sign без формы). Первая боевая форма в sbbol (реквизиты в переводе между счетами, payorder) пошла обратным путём: текст поля берётся из стейта, каждая буква — интент; замеров лага эха нет. Главный незакрытый риск — нужен разбор формы под нагрузкой
Обучение команды (Elm-модель: команды/подписки вместо suspend-reduce)Прецеденты: Elm/MVU, TCA; разобранный пилот qr-sign и боевые экраны sbbol как живые примеры (§6); пошаговое руководство и примеры переноса. MEL Cookbook — следующий шаг
Композиция host/child для мульти-модельных экрановМост host/child не собран (§4.3) — открытый вопрос (§11)
Статический граф — эвристика по PSI (headless — по именам, в студии — Analysis API): цели переходов, результаты команд, источники подписок разбираются best-effort; переход вне значения клетки или во внешней функции в граф не попадает (§8.2)Рантайм — авторитет: полноту настоящего Graph стережёт melGraphAssert, статика лишь показывает; граф-линтер помечает неразрешённые цели; формы переходов закреплены тестами :tools:mel-graph. Golden-сверки статика↔рантайм нет (§8.2)
IDE-плагин завязан на внутренние API Android Studio — они ломаются между поколениями платформы (в 261 у рендера превью удалён dispose() и сменился тип getRenderedImage())Разъехавшиеся методы зовутся рефлексией — рендер превью работает и до, и после API 261; при отказе Analysis API резолвер откатывается на эвристику по имени. Ядро и detekt-набор от плагина не зависят
Канал эффектов — у одного потребителя: второй коллектор model.effects разделит события, а не получит копиюЭффекты собирает только MelEffectHost (через внутренний allEffects()); ручной сбор ловит detekt MelNoManualEffectCollection (Defect) и одноимённая инспекция

11. Что реализовано и что осталось открытым

Итоговая карта зрелости — чтобы документ не выдавал желаемое за сделанное:

БлокСтатус
Ядро рантайма: цикл, маршрутизация по стеку, команды (+ прогресс; ключи общие для машины), подписки (дифф, per-state предикаты active<…>, бэкофф), дебаунс на входе send, эффекты-буфер, дроп+лог; резолв «самой специфичной регистрации» для клеток, эффектов, вью и дебаунса (четыре реализации одного правила)реализовано
Стек состояний: push/pop/popTo/next, фоновая маршрутизация self-update; back без клетки — pop() ядра, на дне MelExhaustedреализовано
Compose: MelScreen builder, MelContent (AnimatedContent + оверлеи + SaveableStateHolder по «индекс + класс»), MelEffectHost (буфер + repeatOnLifecycle), дефолтные хендлеры MelCloseEffect и MelExhausted, Back с root-back по handlesBack, transitions { between<A, B> }реализовано
Тест-набор: melGraphAssert, Transition.assert, table-driven через Graph.reduceреализовано
Detekt: 26 PSI-правил по маркерам, 5 группреализовано
IDE-инспекции: 26 localInspection, парные detekt-правилам, общий исходник PSI-хелперов; quick-fix у двухреализовано
Граф-линтер lintScreen: 13 структурных проверок поверх извлечённого графареализовано
Публикация ru.sberbank_sbbol.mel (core/test/compose-material/detekt/graph) 0.2.0 в git-трекаемый maven-репозиторий mel-maven/mel-repo, потребитель — sbbol; плагин 0.1.3 — zip / custom plugin repository; подключаемый MelLogреализовано
Боевые экраны sbbol на MEL (ограничения по счёту, пуллер штрафов, пуллер создания, поиск банка, реквизиты «Между счетами») с тестами машинреализовано
Мост host/child (bridge(child){…}): рантайм + маркер «адресовано хосту»design
Opt-in снапшот черновика (snapshot { capture<…> } + restored)design
Core-паттерн пейджинга (страницы/флаги — факты; подгрузка — команда с ключом страницы) — без него списочные SimpleMvi не поедутdesign
Текстовый ввод (TextFieldState + факт с ревизией) — ни на пилоте, ни в боевых формах не отработан (§10)не проверено
Граф-инструмент (:tools:mel-graph + IDE-плагин): экстракция из PSI (стейт-одиночка, ветвление цели next, хвостовые хелперы), тул-окно (каталог экранов, граф состояний на Java2D/ELK, «Превью», «Проблемы»), навигация в код, PNG/Mermaid; рендер превью и до, и после API 261реализовано
Плагин: quick-fix к остальным 24 инспекциям, включение карты экранов, переходы вне значения клетки в экстракторе, ложный «next в оверлей» на самопереходе оверлея; FIR-чекер компилятораdesign
Ядро: отправка интента из эффект-хендлера через контекст; ключи команд в пределах своей секции (секция различается по классу); устойчивая идентичность записи стека для сохраняемого состояния вместо «индекс + класс»; одно общее правило специфичности вместо четырёх реализаций; дебаунс только для интентов; закрытие экрана одним дефолтным хендлером и объединённый поток эффектов как часть контракта MelModelв планах
Тест-набор: проверка формы контракта (члены функционального типа, изменяемые свойства, листья не data-классы) прямо в melGraphAssert, с точечным послаблением по причинев планах
Detekt + инспекция: именованные аргументы при конструировании машин и секций с длинным списком зависимостейв планах
Подписки с параметром из стейта ((SS) -> Flow<I> с рестартом по ключу-от-стейта)по первому кейсу
MEL Cookbook (~10 рецептов «задача → код целиком») и перенос payorder/mass-sign целиком (в sbbol на MEL пока отдельные пуллеры и формы)по итогам пилотов

Решённые в дизайне вопросы (зафиксированы реализацией ядра): сериализация стека — стек не сохраняется, восстановление в базовый стейт (§4.6); имя пакета — core/mel, отстройка от нумерации MVI сознательная; команды с прогрессом — commandWithProgress с обязательным финалом + cancel-набор перехода (§4.1); гонка close-vs-cancel — enqueue через trySend (§4.2); терминал MelExhausted — отдельный внутренний канал, публичный effects: Flow<E> без unchecked-каст (§4.2); back без клетки — фиксированное правило ядра: pop(), на дне MelExhausted (§7.2).


Основание: исходная спека-драфт целевой архитектуры (sbbol mel-design.html, 2026-06-13) и её реализация в :core:mel; сверка 2026-10-02 — по коду ru.sberbank_sbbol.mel 0.2.0 (модули :core:*, :tools:*) и боевым MEL-экранам sbbol. Разборы болей — payorder (PayOrderComponent.kt: 205 биндингов, debounce-список UniqueUtils, интент UseLoader), mass-sign (screenState-мешок, ShowLoader/ShowShimmering-эффекты, рукописный errorHandler), QR-sign (гонка cold-start). Прецеденты архитектуры: The Elm Architecture (Cmd/Sub), The Composable Architecture (отмена эффектов по ключам, подписки от стейта), MVIKotlin/Orbit (каналы side effect).