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

Область: модули :core:mel + :core:mel-test, опубликованы как ru.mel:core / ru.mel:test / ru.mel:detekt 0.1.0  ·  Дата: проект 2026-06-13, реализация и публикация 2026-06-14  ·  Статус: ядро и пилот собраны, тесты зелёные; сосуществует с v3/simplemvi
Происхождение: этот документ — причёсанная под факт реализации версия исходной спеки-драфта целевой архитектуры (sbbol mel-design.html). Примеры, привязанные к sbbol (payorder, mass-sign, auth), сохранены как откуда пришли боли; рабочий пример — реально собранный пилот qr-sign в :sample:migration. Разделы, помеченные design, спроектированы, но в коде ещё не реализованы — см. §11.

Об имени. MEL = M + EL: Machine + Эль(нар) — рабочее название, автор склеен в одном слове. Второе имя, шуточное: TEMAThe Elnar Machine Architecture, прямой потомок TEA (The Elm Architecture). Отдельное имя — осознанная отстройка от нумерации MVI: MEL не пятая версия зоопарка, а его замена. В коде это пакет ru.mel.core.mel (разложенный по подпакетам contract/effect/transition/graph/machine/log/runtime/compose), публикуемый под координатами ru.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) }.
kotlin-reflect на каждом диспатче; eager-создание ~100 обработчиков на модель Рефлексии в горячем пути нет — lookup по Class (findCell). Рефлексия — только в тесте полноты (перечисление sealed-листьев). Создаётся одна машина (+ её секции) на модель.

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

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

Код разложен по подпакетам ru.mel.core.mel.* — слои без циклов: contract · effect · transition · loggraphmachineruntimecompose.

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

ИмяЧто этоТип/файл в :core:mel
MEL (шутл. TEMA)Имя подхода: Machine + Эль(нар); потомок TEA. Замена зоопарка, не версия.пакет ru.mel.core.mel
StateSealed-стейты экрана, владеют фактами. Корень реализует маркер.MelState (маркер, contract/Contracts.kt)
IntentЕдинственный вход машины: от UI, от команд, от подписок. Привязан к стейту типом.MelIntent (маркер)
EffectOne-shot к UI (снек, навигация, диалог). Данные, не лямбды.MelEffect (маркер); сквозные — MelCommonEffect
CommandАсинхронная работа, рождённая переходом и обязанная завершиться интентом. Отмена — cancel(key) из перехода; дубль ключа = replace. Прогресс — commandWithProgress.Command<I> (transition/Command.kt); билдеры в MelOps
SubscriptionПоток интентов с ключом и стейт-скоупом (Elm: Sub). Живёт, пока стейт её скоупа в стеке.Subscription<I> (graph/Graph.kt); скоуп — vararg-клаузы active<…>()
reduce / клеткаФункция перехода одной пары (стейт, интент) — юнит логики.Cell<S, E> (graph/Graph.kt)
SectionСвязный набор клеток (+ подписок) с @Inject-зависимостями — переиспользуемый юнит (§6.7).Section<S, I, E> (machine/Section.kt)
Transition / MelStartРезультат клетки: (op, effects, command, cancel); операция стека — StackOp. Рождение машины — узкий MelStart (to + effects/command/cancel, toTransition()), §4.1.Transition, MelStart, StackOp (transition/Transition.kt)
билдеры (ReduceScope)Словарь переходов next · stay · push · pop · popTo<S>; команды — command / commandWithProgress; рождение — startNext. Общий receiver клетки/start/графа.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.compose/MelBack.kt
MelPopRequestНейтральный к навигации маркер: неотмаршрутизированный интент с ним → дефолтный pop() стека ядра. Ядро знает про свой стек, не про «Back».маркер в contract/Contracts.kt
MelExhaustedТерминальный lifecycle-эффект: попнут последний фрейм стека (MelPopRequest на дне либо pop() со дна). Трактовку «закрыть экран» даёт дефолт интеграции, не ядро.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Типизированный контекст эффект-слоя: база несёт роутер; 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/cancel), а не 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 клетки/start/графа).
//   MelOps : ReduceScope (билдеры override), GraphBuilder : ReduceScope; Cell.reduce
//   получает receiver ReduceScope, так что s,i -> next(…) одинаков в машине, секции и тесте.
//   Ключи команд/подписок — типизированные value class: CommandKey/SubscriptionKey (§4.1).
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>
inline fun <reified T : S> popTo(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 (база машины и секции) + узкий билдер рождения:
//   startNext(to, …): MelStart — отдельный узкий тип старта, НЕ Transition (§4.4).
protected fun startNext(to: S, vararg effects: E, command: Command<I>? = null,
    cancel: Set<CommandKey> = emptySet()): MelStart<S, E>

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

class GraphBuilder<S : MelState, I : MelIntent, E : MelEffect> : ReduceScope<S, I, E> {
    /** Клетка: (стейт, интент или 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.2): стейт + типизированный предикат именно на этот стейт.
        Конструируется 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.3), а межэкранные — эффектом + роутером (§7.4); ни то, ни другое моста не требует.

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)       // push пуллера — выезд снизу
}

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 с ключом записи: push не убивает remember-состояние подложки, а ушедшая из стека запись чистится из holder'а (повторный push того же типа не воскрешает 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()) {
        // context = ::MelSnackContext (база-роутер + snackbar-host) из :core:mel-compose-material (§5 п.1)
        MelScreen(model, 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 из core: только роутер. Свои инструменты (snackbar host, пейджеры) — перегруз MelScreen(…, context = { defaults -> XContext(defaults.router, …) }). Готовое material-расширение — MelSnackContext(router, snackbar) в отдельном модуле :core:mel-compose-material (material3 не утекает в ядро, §5 п.1)
effectsДефолтные хендлеры MelCommonEffect из core: эффект реализует маркер MelCloseEffect (и терминальный MelExhausted) — закрытие через роутер без строчки в effects{}. Регистрация специфичнее переопределяет дефолт (легально)
transitionsCrossfade по классу вершины; enter/exit оверлеев (§4.5)
BackBackHandler гейтнут на свой стек: глубже одного → MelBack → дефолтный pop() ядра. На КОРНЕ включается ровно когда у машины есть клетка на MelBack для текущей вершины (MelModel.handlesBack) — машинно-владеемый root-back; иначе на корне Back отдаётся системе (NavHost/фрагменты). Перехват — клетка на MelBack (§7.4)
Сохранение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 (§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

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

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.3)
        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.3)
    on<Content, UpdatesBroken>{ s, i -> stay(ShowError(i.message)) }
    on<Content, GoToPayment> { s, _ -> stay(NavigateToPayment(s.doc.id)) }     // навигация — данные (§7.4)
    on<Content, CloseClick>  { _, _ -> stay(Close) }

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

    // подписка со скоупом из ДВУХ стейтов (§7.2): обновления нужны и в 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>(::confirmSign)
        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))
            },
        )
}

Что произошло с болями экрана: зависший лоадер невозможен (ошибка — это клетка 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.3), а не к замороженному снапшоту. Итог детерминирован — Content(signed). Кнопка сэмпла «Эмулировать пуш» шлёт обновление в шину снаружи экрана — экран узнаёт о нём той же подпиской, демонстрируя живой скоуп. Всё это покрыто табличным тестом QrSignMachineTest (§8).

Создание модели — фабрикой melViewModel. Рантайм-фасад экрана — MelModel<S, I, E> (stack/state/effects + send(I)/pop()), его реализует общий MelViewModel. Пилот не пишет 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) — и всё.

Доктрина — три правила (зашиты в KDoc Section)

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

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

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

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 не трогает — он живёт за интерфейсом MelRouter в MelEffectContext. MEL стандартизирует только край экрана.

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

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

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

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

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

// пилот: assert-DSL клетки (TransitionAssert)
SignSection(interactor).confirmSign(Confirm(doc), ConfirmSign).assert {
    to<Confirm> { it.isSigning }
    command("sign")
    noEffects()
}

// пилот: страж полноты — проходит без единого исключения (граф закрыт по построению)
class QrSignGraphTest {
    @Test fun graph() = melGraphAssert(QrSignMachine(QrSignArgs("op-1"), interactor, docUpdates))
}

QrSignMachineTest — 17 табличных кейсов: рождение, каждая клетка Content/Confirm/Stub, гейт дабл-клика, фоновый DocUpdated под пуллером, стейл-интент чужого стейта. Плюс QrSignGraphTest. Итого по экосистеме MEL тестов: :core:mel — 27 (рантайм: цикл, команды, подписки, эффекты), :core:mel-test — 29 (сам граф-ассерт и DSL), :tools:detekt-rules — 41, пилот — 18.

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

8 правил дисциплины, подключены к экранным модулям (:sample:migration, :app). Все правила — PSI, без type resolution: IDE-плагин detekt не поддерживает type resolution для подсветки на лету (известное ограничение), поэтому от TR отказались — так все 8 правил работают и в Studio на лету, и в Gradle-таске. Контракты детектятся по маркерам (MelState/MelIntent/MelEffect/MelFacts из contract/Contracts.kt): хелпер идёт BFS по супертипам в пределах файла в поисках маркера — легаси *State/*Effect без маркера не трогается.

ПравилоЛовитЗакрывает
MelNoTypeChecks (error)is/as/when-is по типам контрактов, но только внутри класса-MelMachine/Section (PSI-якорь убирает ложные на legacy SimpleMvi)§5 п.6
MelImmutableFacts (error)var, мутабельные коллекции, MutableState в классе с маркером MelState/MelFacts§4.5
MelNoInstrumentsInState (error)Инструменты в стейте: TextFieldState, SnackbarHostState, пейджеры, compose.*/android.*§5 п.1
MelEffectsAreData (error)Функциональные типы в классе с маркером MelEffect (анти-UseSnackbar { })§5 п.4
MelNoMutableEffectContext (error)MutableState/var в MelEffectContext§5 п.1
MelNoCoroutinesInMachine (error)launch/GlobalScope/CoroutineScope в MelMachine/Section — асинхронщина только командами и подписками§4.2
MelSubscribeOnError (warning)subscribe без onError — отсутствие должно быть осознанным§4.2
MelMigrationRatchet (warning)Новые наследники SimpleMviViewModel/v3 MviModel вне allowlist§9

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

Собрано — модуль :tools:mel-graph + IDE-плагин :tools:mel-idea-plugin: статическая экстракция графа из PSI (MelGraphExtractor / headless-MelPsiWalker для CI); тул-виндоу «MEL» — каталог экранов слева (фильтр по модулю, поиск, скрытие тестовых) и живая диаграмма стейтов справа (собственный Java2D-рендер с layered-layout, без JCEF: узлы по ролям start/overlay/terminal, дашированные рёбра команд/pop, иконки подписок); навигация из узла/ребра/инспектора в исходник (OpenFileDescriptor по SourceRef); панель-инспектор узла (факты, подписки, локальные экшены, переходы — каждый ссылкой в код); экспорт Mermaid в буфер и PNG на диск. Граф несёт overlay-стейты, ключи команд и их результаты, дебаунс, подписки.

Golden-сверка статика↔рантайм собрана: MelGraphCrossCheckTest в пилоте сверяет граф PSI-уокера с настоящим QrSignMachine.graph, где рантайм — авторитет (статике расходиться нельзя) — это и закрывает риск «статический анализ соврал». Отдельного именованного MelDiagram.golden(…) нет: роль закрыта связкой MermaidEmitter + MelGraphCrossCheckTest.

Ещё не сделано design: code-инспекции с quick-fix в IDE (в plugin.xml только toolWindow; дисциплину держат detekt-правила §8.1, но «на лету» в редакторе их нет); вкладка миграции v3/SimpleMvi → MEL в плагине. Дверь к 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)
Дисциплина «эффекты — данные», «факты не в контексте» держится на конвенции8 detekt-правил (§8.1), работают и в IDE, и на CI — реализовано
Текстовый ввод через машину — лаг эха и прыжки курсора (цикл на фоне)Правило §4.5 «буква — Compose, смысл — машина» спроектировано, но на пилоте не проверено (qr-sign без формы). Главный незакрытый риск — нужен пилот с формой
Обучение команды (Elm-модель: команды/подписки вместо suspend-reduce)Прецеденты: Elm/MVU, TCA; пилот qr-sign как живой пример; KDoc в ядре подробный. MEL Cookbook — следующий шаг
Композиция host/child для мульти-модельных экрановМост host/child не собран (§4.3) — открытый вопрос (§11)

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

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

БлокСтатус
Ядро рантайма: цикл, маршрутизация по стеку, команды (+ прогресс), подписки (дифф, per-state предикаты active<…>, бэкофф), дебаунс, эффекты-буфер, дроп+логреализовано
Стек состояний: push/pop/popTo/next, фоновая маршрутизация self-updateреализовано
Compose: MelScreen builder, MelContent (AnimatedContent + оверлеи + SaveableStateHolder), MelEffectHost, дефолтные хендлеры MelCommonEffect, Back, transitionsреализовано
Тест-набор: melGraphAssert, Transition.assert, table-driven через Graph.reduceреализовано
Detekt: 8 PSI-правил по маркерамреализовано
Публикация ru.mel:0.1.0 (core/test/detekt) в Nexus; подключаемый MelLogреализовано
Пилот qr-sign (контракт + машина + секция + UI + превью + тесты)реализовано
Мост host/child (bridge(child){…}): рантайм + маркер «адресовано хосту»design
Opt-in снапшот черновика (snapshot { capture<…> } + restored)design
Core-паттерн пейджинга (страницы/флаги — факты; подгрузка — команда с ключом страницы) — без него списочные SimpleMvi не поедутdesign
Текстовый ввод (TextFieldState + факт с ревизией) — пилот с формойне проверено
Граф-инструмент (:tools:mel-graph + IDE-плагин): экстракция из PSI, тул-виндоу (каталог экранов + диаграмма стейтов на Java2D), навигация в код, инспектор узла, Mermaid/PNG-экспорт, golden-сверка статика↔рантайм (MelGraphCrossCheckTest)реализовано
Плагин: code-инспекции с quick-fix в IDE, вкладка миграции v3/SimpleMvi → MEL; FIR-чекер компилятораdesign
Подписки с параметром из стейта ((SS) -> Flow<I> с рестартом по ключу-от-стейта)по первому кейсу
MEL Cookbook (~10 рецептов «задача → код целиком») и перенос payorder/mass-signпо итогам пилотов

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


Основание: исходная спека-драфт целевой архитектуры (sbbol mel-design.html, 2026-06-13) и её реализация в :core:mel (2026-06-14). Разборы болей — 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).