Skip to content

Два скилла дисциплины. Скилл проектирования для opus и скилл реализации для sonnet

«Дисциплина — это мост между целями и достижениями.»

— Jim Rohn

В предыдущей статье мы вывели дисциплину: программа = дерево модулей с контролем диапазонов, юнит-тесты по формуле «1 + ветки антецедента», корректность доказывается по построению. Там была теория, оформленная как практическая методичка.

Этой статьёй закрываем разрыв между «понятно как» и «понятно что положить в Claude Code». На выходе — два самостоятельных артефакта:

  • program-design.skill — скилл для opus. Принимает функциональное требование, отдаёт пакет проектной документации.
  • program-implementation.skill — скилл для sonnet. Принимает пакет, отдаёт код по тикетам через Trunk Based Development.

Связующая нить — vertical slice architecture: каждый вход API режется в отдельный поток сверху вниз, со своим адаптером, своей бизнес-логикой, своим модулем I/O. Это резко упрощает и проектирование, и сборку бэклога, и параллельную работу нескольких агентов.

В следующей статье оба скилла прикручиваются к ubik-life/passkey-demo-api. Здесь — только сами скиллы и обоснование, почему они выглядят именно так.

В этой главе рассмотрим:

Зачем разделять скилл на два

Opus и sonnet — разные инструменты. Opus сильнее в архитектурном мышлении, ему дорого думать, дёшево не написать ни строчки кода. Sonnet быстро и дисциплинированно превращает спецификацию в код, но не должен принимать архитектурных решений на лету.

Правильный режим работы такой:

[ функциональное требование ]
            |
            v
+--------------------------+
|  opus: program-design    |   <- архитектурное мышление
|  думает,  предлагает,     |
|  итерирует с оператором  |
+------------+-------------+
             |
             v
[ пакет проектной документации (artifact bundle) ]
             |
             v
+--------------------------+
|  sonnet: program-impl    |   <- дисциплинированная реализация
|  по тикетам, по TBD,     |
|  с тестами, с PR         |
+------------+-------------+
             |
             v
[ зелёный main, проходящий CI ]

Между этими двумя миром стоит оператор — человек, который аппрувит ключевые развилки на стыке. Без него любой агент рано или поздно начнёт делать архитектурный выбор, не имея на него мандата.

Поэтому скиллов два, а не один большой. У каждого своя зона ответственности и свой Definition of Done. Если их слить — получим монстра, в котором opus начнёт писать код, sonnet начнёт перепроектировать модули, оператор перестанет понимать, на каком этапе он находится.

Vertical slice — несущая конструкция

Классическая «луковая» архитектура режет программу горизонтально: контроллеры → сервисы → репозитории. На бумаге красиво. В реальности — каждый эндпоинт API размазан по трём-пяти файлам в разных слоях, и любое изменение требует синхронной правки во всех слоях.

Vertical slice архитектура режет программу вертикально: каждый вход API — это самостоятельный «срез» от адаптера до I/O. Срезы между собой не делят промежуточные классы, не зовут общие сервисы, не наследуются. Они изолированы и сходятся только в общих структурах данных и в общем модуле I/O.

       Внешние входы (HTTP / Broker / gRPC / CLI)
   +----+----+----+----+----+
   | A  | B  | C  | D  | E  |    <- эндпоинты/триггеры, каждый — свой slice
   +----+----+----+----+----+
   | A  | B  | C  | D  | E  |    <- ИНГРЕСС-АДАПТЕР: ТОЛЬКО парсинг
   +----+----+----+----+----+       (внешнее представление → Request)
   | Ah | Bh | Ch | Dh | Eh |    <- ГОЛОВНОЙ МОДУЛЬ slice'а:
   +----+----+----+----+----+       строит доменную структуру,
                                    оркестрирует пайп логики и I/O
   | A  | B  | C  | D  | E  |    <- модули логики slice'а:
   +----+----+----+----+----+       конструкторы доменных типов
                                    (валидация — здесь!)
   | Aio| Bio| Cio| Dio| Eio|    <- модуль I/O slice'а: запись/чтение БД,
   +----+----+----+----+----+       публикация события, вызов внешнего API
            |    |    |
            v    v    v
        +-----------------+
        |   общая БД      |
        +-----------------+

Ингресс-адаптер — это обобщающий термин. Конкретная форма зависит от типа входа slice'а:

  • HTTP-эндпоинт — для синхронных API (REST, Webhook).
  • Потребитель брокера — для асинхронных событий (Kafka, RabbitMQ, MQ).
  • gRPC-метод — для типизированных синхронных вызовов.
  • CLI-команда / cron-триггер / файловый watcher — для пакетных и фоновых задач.

Сервис может содержать slice'ы с разными типами входов одновременно: часть slice'ов — HTTP-эндпоинты, часть — потребители брокера. Дисциплина для всех одна: ингресс-адаптер только парсит, валидация в конструкторах, головной модуль оркеструет.

Поверх всех slice'ов — инфраструктурный модуль приложения: один на всю программу, инициализирует общие зависимости (БД, брокер, логгер, конфигурация) и подключает каждый slice к его типу входа: HTTP-роутер для HTTP-slice'ов, подписку потребителя для брокер-slice'ов, регистрацию gRPC-методов и так далее. Этот модуль I/O-шный, бизнес-логики в нём нет.

Что это даёт в нашей дисциплине проектирования.

Один slice — одна задача. Эндпоинт API или триггер брокера соответствует одному use case. Slice его и обслуживает целиком: от входящего запроса (HTTP, события из брокера, gRPC-вызова) до записи в БД и публикации события. Один slice — это одна цепочка модулей с одним входом и одним выходом, ровно как мы её описывали в предыдущей статье.

Slice независим. Можно проектировать, реализовывать, тестировать и деплоить срезы по одному. Если завтра меняется логика регистрации — мы трогаем только slice POST /v1/registrations, остальные четыре среза не задеты. Это превращает бэклог в линейную последовательность тикетов без скрытых зависимостей.

Параллельная работа агентов. Если у нас пять срезов, мы можем запустить пять sonnet-сессий параллельно, каждая работает в своей ветке над своим срезом. Не сталкиваются — потому что не лезут в общий код.

Простой шаблон тикета. Тикет «реализовать slice X» = реализовать адаптер X + логику X + I/O X + юнит-тесты + сделать зелёным компонентный тест slice'а. Один тикет — одна ветка — один PR.

Голова slice'а описывает его логику. У каждого slice'а есть свой головной модуль — модуль-оркестратор. Он получает запрос от ингресс-адаптера, строит из него доменную структуру (валидированный Command), последовательно вызывает модули логики и I/O, возвращает Result. Именно этот модуль читается как «конспект работы slice'а» — открыл, увидел весь пайп исполнения за пять-десять строк.

Инфраструктурный модуль приложения один. Он I/O-шный, технический: HTTP-сервер (если есть), потребитель брокера (если есть), пул БД, логгер, конфигурация, регистрация slice'ов на их входы. Бизнес-логики в нём нет, ни байта. Он не знает, что делают slice'ы — он только подключает каждый ингресс-адаптер к его источнику событий. Никакой оркестрации между slice'ами в инфраструктурном модуле — её нет по построению.

Запомни: один эндпоинт API → один slice → один тикет → одна ветка → один PR. Эта цепочка определяет всё дальнейшее.

Граница адаптера и логики. Конструкторы вместо фабрик

В классической трёхзвенке валидацию часто прибивают к контроллеру или DTO-аннотациям. Это удобно, но архитектурно неверно: контроллер не знает домен, у него нет права решать, валиден ли «возраст 17» или «email без точки в TLD». Знание о допустимых значениях принадлежит домену, значит и валидация — там.

В нашей дисциплине граница проходит так:

Ингресс-адаптер делает только парсинг. Его задача — превратить внешнее представление запроса в типизированную структуру Request, понятную доменной логике slice'а. Никакой проверки бизнес-смысла.

Форма ингресс-адаптера зависит от типа интеграции:

  • HTTP-эндпоинт — распаковать JSON тела, прочитать заголовки, достать path-параметры. Если JSON синтаксически невалиден — 400 Bad Request.
  • Потребитель брокера (Kafka, RabbitMQ, MQ) — десериализовать payload сообщения, прочитать headers/properties. Если payload не распарсился по схеме — отправить в DLQ с диагностикой.
  • gRPC-метод — payload уже типизирован protobuf'ом, адаптер чаще всего вырождается до перекладки в доменный Request.
  • CLI / cron / файловый триггер — распарсить аргументы или имя файла; невалидный аргумент → ненулевой exit-код.

Во всех формах ответственность одна и та же: байты → Request. На этом ответственность ингресс-адаптера заканчивается.

Логика slice'а делает валидацию через конструкторы доменных структур. Если данные невалидны — конструктор не возвращает структуру, он возвращает ошибку. Объект просто не создаётся. Дальше по пайпу проходят только корректные доменные значения.

Это и есть «модули не собираются на невалидных данных». В ООП-языках это обычно реализуется фабричными методами: приватный конструктор + публичная статическая фабрика, возвращающая Result<T, Error>.

Жёсткое правило одного data-аргумента

«Один вход и один выход» в дисциплине трактуется буквально: на вход каждого узла дерева приходит ровно одна data-сущность. Либо доменная структура (Command, Entity, Session), либо Request DTO из ингресс-адаптера, либо ничего.

Зависимости (*sql.DB, клиент брокера, clock.Clock, конфиг, логгер) — это не data. Они инжектятся сбоку (через DI / receiver / closure / контекст), и в спецификации описываются отдельной строкой Dependencies:. На стрелках графа вызовов их не показываем — там только доменные структуры.

Алгоритм проверки. На каждом узле дерева посчитать число data-аргументов:

  • 0 или 1 — модуль контрактуется, идём дальше.
  • 2 и больше — стоп. Завести доменную сущность, которая объединит эти аргументы, и добавить отдельный узел-конструктор NewT(...) для её сборки выше по пайпу. Пересчитать.

Антипример (как НЕ надо):

persistRegistrationSession(id, handle, challenge, ttl, db) -> error
                          ^^^^^^^^^^^^^^^^^^^^^^^^^^  ^^
                          4 data-аргумента            dep

Сигнатура с пятью «протекающими» полями. По дисциплине — стоп.

Как надо:

NewRegistrationSession(id, handle, challenge, ttl, now)  -> RegistrationSession
                                                            (доменная сущность)
persistRegistrationSession(s)                             -> error  [dep: db]

Появился новый узел-конструктор NewRegistrationSession, у I/O-модуля один data-аргумент. Пайп головного модуля стал длиннее на одну строку — это дешёвая цена за инкапсуляцию домена и читаемость.

Конструктор подтипа вместо guard-функции

Если в логическом шаге пайпа появляется сигнатура имя(вход: Domain) -> Result<(), Error> (или (input) -> error в Go), и единственная цель шага — отбраковать вход с ошибкой, это сигнал. Такой шаг — guard. Инвариант не закреплён в типе: после checkX(entity) структура entity не изменилась, и любой другой код может принять её без проверки.

Правило не относится к I/O-модулям с эффект-сигнатурой Result<(), Error> (публикация события, удаление записи, write-tx без возврата ID). У них нет полезного выхода, который можно было бы закодировать в тип; они трубы.

Чинится так: завести подтип, несущий инвариант в типе. Заменить guard на конструктор подтипа.

Антипример:

ProcessSessionAssertion(req) -> Response:
    | NewSessionCommand(req)            -> SessionCommand
    | loadSession(cmd.id)               -> Session
    | checkSessionFresh(session, now)   -> ()             <-- guard
    | verifyAssertion(session, ...)     -> AssertionResult

Как надо:

ProcessSessionAssertion(req) -> Response:
    | NewSessionCommand(req)                       -> SessionCommand
    | loadSession(cmd.id)                          -> Session
    | NewFreshSession({session, now})              -> FreshSession   <-- подтип
    | verifyAssertion(fresh, ...)                  -> AssertionResult

FreshSession — отдельная доменная структура. NewFreshSession проверяет инвариант (now < session.ExpiresAt()) и возвращает либо FreshSession, либо доменную ошибку (ErrSessionExpired). Шаги ниже по пайпу принимают FreshSession, не Session.

Это даёт двойную гарантию. На этапе компиляции система типов не пропустит вызов verifyAssertion(session, ...) с обычной Session — программа просто не соберётся, разработчик увидит ошибку немедленно. На этапе исполнения программа на просроченной сессии вернёт доменную ошибку ErrSessionExpired из NewFreshSession, пайп прервётся, в I/O ничего не уйдёт. Подтип становится носителем инварианта: если в твоих руках FreshSession, она по построению свежая — не нужно перепроверять. Так работают «типы, которые всегда в валидном состоянии»: невалидное значение нельзя ни создать, ни получить, ни передать дальше по пайпу.

Автономный I/O-объект

Каждый I/O-модуль проектируется как автономный объект, инкапсулирующий свою зависимость. Головной модуль знает только методы объекта (его API), не его внутренние зависимости. Имя объекта — по типу интеграции:

Интеграция Имя объекта Зависимость (скрыта внутри)
База данных Store *sql.DB
Внешний HTTP API Client *http.Client + baseURL
Брокер сообщений Publisher / Consumer соединение брокера
Файловая система FileStore *os.File / io.Writer

В контракте I/O-модуля строка Input (data): — одно доменное сообщение, строка Dependencies: — прочерк (зависимость инкапсулирована в объект). В Deps головного модуля — поле типа Store / Client / Publisher, не сырая зависимость. Признак нарушения: сырой *sql.DB, *http.Client или broker-conn в зависимостях контракта или в Deps head-модуля. Это значит — IO-объект не введён, надо вернуться и завести.

Сами объекты — трубы: внутри метода «взять доменное сообщение → вызвать внешнюю систему → вернуть результат или ошибку». Никаких условных ветвлений по данным, никаких трансформаций. Единственное допустимое ветвление — маппинг кодов ошибок внешней системы на доменные ошибки (SQLITE_BUSY → ErrDBLocked). Вся бизнес-логика живёт в модулях логики, не здесь.

Go-альтернатива фабричным методам

В Go нет приватных конструкторов и статических методов в ООП-смысле, но идиоматическая альтернатива есть и она даже проще: package-level конструктор NewT(...) (T, error) + неэкспортируемые поля структуры.

Как это устроено:

// Файл: slice/registrations/domain/handle.go
package domain

// Handle — валидированный пользовательский handle.
// Поля неэкспортируемые: создать Handle снаружи можно ТОЛЬКО
// через NewHandle, который проверяет домен.
type Handle struct {
    value string
}

// NewHandle — единственный способ получить Handle.
// Возвращает ошибку, если значение не удовлетворяет правилам домена.
func NewHandle(raw string) (Handle, error) {
    if raw == "" {
        return Handle{}, fmt.Errorf("handle: empty")
    }
    if len(raw) > 64 {
        return Handle{}, fmt.Errorf("handle: too long (max 64)")
    }
    if !handleRegex.MatchString(raw) {
        return Handle{}, fmt.Errorf("handle: invalid format")
    }
    return Handle{value: raw}, nil
}

// Value — единственный способ прочитать значение наружу.
func (h Handle) Value() string { return h.value }

Что это даёт:

  1. Невалидный Handle невозможно создать. Поле value не экспортировано, литерал domain.Handle{value: "..."} снаружи пакета не скомпилируется. Ноль-значение domain.Handle{} существует, но оно явно отличимо и используется только для ранних возвратов из конструктора.
  2. Валидация — в одном месте. Все правила handle живут в NewHandle. Любой код, получивший Handle, может ему доверять без проверки.
  3. Сигнатура честная. NewHandle(raw string) (Handle, error) — видно сразу, что метод может провалиться. Вызывающий обязан разобрать ошибку, иначе компилятор заругается на неиспользуемое значение.
  4. Чистая функция. Конструктор не лезет в БД, не пишет в логи, не зависит от глобального состояния. Юнит-тестируется тривиально по формуле: 1 happy + ветки антецедента.

Для составных доменных структур то же самое:

// Файл: slice/registrations/domain/registration_command.go
package domain

type RegistrationCommand struct {
    handle Handle
    email  Email
    bday   BirthDate
}

// NewRegistrationCommand собирает доменную команду из Request,
// пришедшего из HTTP-адаптера. Если хоть одно поле невалидно,
// команда не собирается — возвращается ошибка с указанием поля.
func NewRegistrationCommand(req Request) (RegistrationCommand, error) {
    handle, err := NewHandle(req.Handle)
    if err != nil {
        return RegistrationCommand{}, fmt.Errorf("handle: %w", err)
    }
    email, err := NewEmail(req.Email)
    if err != nil {
        return RegistrationCommand{}, fmt.Errorf("email: %w", err)
    }
    bday, err := NewBirthDate(req.BirthDateString)
    if err != nil {
        return RegistrationCommand{}, fmt.Errorf("birth_date: %w", err)
    }
    return RegistrationCommand{handle: handle, email: email, bday: bday}, nil
}

Эта функция и есть «фабрика» в Go-стиле. Если любое поле невалидно — структура не создаётся, ошибка протекает наружу, оркестратор slice'а ловит её и возвращает ответ ингресс-адаптеру: 400 Bad Request для HTTP, маршрут в DLQ для брокер-потребителя, INVALID_ARGUMENT для gRPC. На этом работа slice'а на невалидном входе заканчивается, в I/O ничего не уходит.

Почему не возвращаем Result<T, Error> из Go-конструктора

В предыдущей статье мы договаривались о форме Result<T, Error> — и это правильная архитектурная абстракция для языков с алгебраическими типами (Rust, Scala, F#, Kotlin). В Go её аналог — пара возвратов (T, error), и это идиома языка: компилятор обеспечивает разбор ошибки, инструменты её понимают, рантайм-цены ноль.

Тащить в Go тип Result[T any] через дженерики можно, но это:

  • ломает идиому, в которую обучен любой Go-разработчик;
  • мешает работе errors.Is / errors.As / errors.Wrap;
  • не даёт ничего сверху обычного (T, error).

Поэтому в messages.md для Go-проекта пишем «контракт: (T, error)» вместо Result<T, Error>. Семантика та же: успех с T или ошибка. Везде, где в скилле для языко-независимого описания стоит Result<T, Error>, в Go-коде это означает (T, error).

Головной модуль slice'а — пайп исполнения

Конструкторы умеют валидировать. Теперь нужно их собрать в работающий slice. Сборкой занимается головной модуль slice'а, и его форма — не «обычный Go/Kotlin-метод с if-ами», а пайп исполнения: явная последовательность шагов, каждый шаг — вызов отдельного модуля, поток данных идёт сверху вниз, ошибка протекает наружу автоматически.

Главное правило: по головному модулю slice'а должна читаться вся программа slice'а. Открыл — за пять-десять строк увидел весь поток. Не «закопано в трёх вложенных if'ах», не «логика собирается на ходу из приватных полей сервиса». Один линейный пайп.

Пример на Kotlin

Реальный код из mq-rest-sync-adapter/WalletBalanceService.kt:

fun process(message: Message<Any>): Mono<Result<WalletBalance>> =
    validateRequest(message)
        .pipe { sendAcceptedReceipt(it) }
        .pipe { getBalance(it) }
        .pipe { sendRestResponse(it) }
        .pipe { sendTransactionReceipt(it) }
        .flatMap { logError(it) }

Шесть строк — вся логика slice'а. Никаких if-ов в process, никаких разборов промежуточных результатов. Каждый шаг возвращает Mono<Result<T>>, оператор .pipe { } пробрасывает успех в следующий шаг, ошибку — в конец цепочки. На границе — validateRequest через ValidRequest.emerge(...) (та же фабрика валидируемой структуры, о которой мы говорили в предыдущем разделе).

Каждая приватная функция-шаг — это один модуль slice'а. validateRequest — конструктор доменной команды. sendAcceptedReceipt, sendRestResponse, sendTransactionReceipt — модули I/O. getBalance — модуль логики, который параметризован вызовом внешнего REST-сервиса. Каждый — со своей сигнатурой (In) -> Mono<Result<Out>>, своим антецедентом, своим консеквентом, своим юнит-тестом.

Пример на Go

В Go нет реактивного pipe, но дисциплина та же. Реальный код из mq-rest-sync-adapter/contract_validator.go — упрощённо:

func (v *ContractValidator) Validate(configPath string) (*ValidationResult, error) {
    // Шаг 1: загрузить конфиг
    config, err := v.loadConfig(configPath)
    if err != nil {
        return nil, fmt.Errorf("CONFIG_ERROR: %w", err)
    }

    // Шаг 2: распарсить спецификацию потребителя
    consumerSpec, err := v.parseSpecWithBaseDir(config.Consumer.SpecPath, configDir)
    if err != nil {
        return nil, fmt.Errorf("PARSE_ERROR (consumer): %w", err)
    }

    // Шаг 3: распарсить спецификацию провайдера
    providerSpec, err := v.parseSpecWithBaseDir(providerPath, configDir)
    if err != nil {
        return nil, fmt.Errorf("PARSE_ERROR (provider): %w", err)
    }

    // Шаг 4: собрать доменную команду из распарсенных спецификаций
    contract, err := NewContractValidate(consumerChannel, consumerSpec, providerSpec)
    if err != nil {
        return nil, fmt.Errorf("DOMAIN_ERROR: %w", err)
    }

    // Шаг 5: применить логику валидации
    if _, err := v.channelValidator.ValidateChannels(contract); err != nil {
        return nil, fmt.Errorf("VALIDATION_ERROR: %w", err)
    }

    return &ValidationResult{IsValid: true /* ... */}, nil
}

Это Railway-Oriented Programming в Go-идиоме: каждый шаг возвращает (T, error), ранний return nil, err обрывает цепочку и протекает наружу с обогащённым контекстом через fmt.Errorf("...: %w", err). Структура та же, что в Kotlin: линейная последовательность вызовов, каждый шаг — отдельный модуль.

Шаг 4 (NewContractValidate(...)) — точка, где из распарсенных артефактов собирается доменная команда через конструктор. Если бы конструктора не было и поля присваивались литералом, мы бы потеряли валидацию по построению. Здесь конструктор гарантирует, что дальше по пайпу идёт только корректный *ContractValidate.

Что обобщаем в скилл

Два примера, два языка, одна форма. Правила головного модуля slice'а:

  1. Линейный пайп. Видна последовательность шагов, без вложенных условий и циклов. Если в головном модуле slice'а появляется for или if x && y, у которого нет очевидной семантики «тут две реальные ветки выполнения, и обе описаны как отдельные slice'ы» — это сигнал, что часть логики просочилась в оркестратор. Унести её в отдельный модуль.
  2. Каждый шаг — отдельный модуль. Не приватный метод-помощник на пять строк, не лямбда — именно отдельный, самостоятельно тестируемый модуль с антецедентом и консеквентом.
  3. Поток через Result / (T, error). Шаги склеиваются через языковую идиому проброса успеха и ошибки: .pipe/flatMap в Kotlin/Scala/Rust, ранний return nil, err в Go, ?-оператор в Rust. Цель одна: ошибка не теряется, успех протекает дальше.
  4. Никакой бизнес-логики в самом пайпе. Пайп оркестрирует, но не считает. Всё, что считает, — внутри модулей-шагов.
  5. Длина — пять-десять шагов. Если получается пятнадцать — slice спроектирован слишком крупно, дробить.
  6. Первый шаг — почти всегда конструктор доменной команды. Превращение Request (или Message, или args) в валидированную доменную структуру через NewT(...) / T.emerge(...). Дальше по пайпу идёт уже доменный объект, не «сырой» вход.
  7. Последний шаг — формирование ответа адаптеру (если slice синхронный) или публикация финального события (если асинхронный).

Если хочется придумать «общую обработку ошибок» в виде middleware — не нужно. Шаги пайпа сами обогащают ошибку контекстом (fmt.Errorf("CONFIG_ERROR: %w", err) в Go, fold/onFailure в Kotlin), ингресс-адаптер на выходе пайпа маппит её в формат, понятный потребителю: HTTP-код по таблице из README для синхронных входов, маршрутизация в DLQ с диагностикой для брокера, gRPC-status для gRPC. Никакой магии, всё видно глазами.

Головной модуль не тестируется юнитами

Этим линейным пайпом нечего юнит-тестировать. Каждый шаг — уже протестированный модуль (конструктор с антецедентом или I/O-объект с режимом отказа). Между ними — только проброс Result / (T, error), никакой собственной логики. Юнит-тест над таким пайпом — на самом деле интеграционный: он либо собирает реальные зависимости (тогда это уже компонентный тест в маске юнита), либо требует тест-дублей (моки, fake-БД, func-поля в Deps под подмену) — а это путь, который дисциплина запрещает напрямую.

Корректность пайпа доказывает компонентный сценарий через реальный вход slice'а: HTTP-запрос для HTTP-slice'а, публикация сообщения в брокер для Broker-slice'а, gRPC-вызов для gRPC-slice'а. Ветки ошибок I/O покрываются сценариями отказа (db_locked, db_disk_full и т. д.), не юнит-тестами головного модуля.

То же касается I/O-модулей и ингресс-адаптера: оба — трубы. Success-ветка I/O зеленится happy-path сценарием slice'а, failure-ветка — сценарием отказа. Адаптер парсит вход и маппит ошибки в формат ответа — алгоритма, который надо проверять юнитом, в нём нет. Все три артефакта — головной модуль, I/O, адаптер — из таблицы юнит-тестов карточки слайса исключаются (см. Шаг 8.1 в скилле проектирования).

Trunk Based Development — операционный режим

Vertical slice определяет, что мы делаем. TBD — как мы это сливаем в main.

Правила TBD простые и жёсткие:

  1. Один main, никаких длинных feature-веток. Ветка живёт часы, в крайнем случае — день.
  2. Каждый PR маленький. Один тикет — один PR. Не «реализовал slice плюс по дороге отрефакторил три соседних» — это два или четыре PR.
  3. Main всегда зелёный. CI прогоняет всё: линт, юнит-тесты, компонентные тесты. Красный main — стоп для всех.
  4. Между тикетами синхронизация с main. Sonnet перед началом нового тикета подтягивает свежий main, создаёт новую ветку от свежего main.
  5. Слияние — fast-forward или squash. История чистая, по одному коммиту на тикет.

Почему именно TBD для sonnet:

  • Маленький PR sonnet проще ревьюить оператору. Огромный PR с десятью модулями — нереально проверить.
  • Маленький PR быстрее проходит CI. Если CI красный — мы знаем, что причина в этом PR, а не в трёх предыдущих.
  • TBD исключает merge-конфликты. Slice'ы независимы, ветки короткие — конфликтовать нечему.
  • TBD естественно ложится на «один тикет — один slice — одна ветка». Они спроектированы друг под друга.

В скилле sonnet'а TBD прошит как последовательность шагов завершения тикета, а не как «следуй best practices». Ниже это будет видно.

Скилл проектирования для opus

Это первый из двух артефактов. Кладётся в репозиторий как skills/program-design/SKILL.md — рядом с скиллом компонентных тестов, не вместо него.

Сам файл — ниже целиком. Для копирования в проект как есть.

# program-design.skill — Проектирование программы по дисциплине рациональной разработки

## Назначение

Скилл для opus. На вход — функциональное требование (FRD, описание задачи).
На выход — пакет проектной документации, по которому sonnet реализует программу.

Метод: vertical slice architecture + структурное программирование +
контракты модулей + формула юнит-тестов.

## Зона ответственности

DO:
- Проектировать схему модулей и контракты.
- Итеративно обсуждать развилки с оператором.
- Готовить бэклог тикетов для sonnet.

DON'T:
- Писать код реализации (это работа sonnet).
- Принимать архитектурные решения без аппрува оператора.
- Добавлять зависимости и технологии без явной аргументации.

## Шаги

### Шаг 0. Прочитать вход

**Обязательные артефакты на входе:**

- FRD или эквивалент (одна-две фразы про задачу).
- **Контракт API.** Для синхронных эндпоинтов — `OpenAPI`. Для
  событий и асинхронных интеграций — `AsyncAPI`. Если сервис
  смешанный (HTTP + брокер) — оба контракта обязательно.
- **Таблица отказов в README** — карта режимов отказа интеграций
  с обязательными колонками: `error.code`, HTTP-статус (или тип
  события), заголовки (например `Retry-After`), действие клиента,
  действие оператора. Это раздел [«Карта режимов отказа»](https://codemonsters.team/blog/2026/04/26/component-tests-skill-passkey-demo/),
  без него компонентные сценарии отказа описать нельзя.
- **Компонентные сценарии Gherkin** для эндпоинтов слайсов уже
  написаны и закоммичены (по [скиллу компонентных тестов](https://codemonsters.team/blog/2026/04/26/component-tests-skill-passkey-demo/)):
  один happy path + сценарий на каждый различимый режим отказа,
  для каждого эндпоинта будущего slice'а. Это **исполняемая
  спецификация**, против которой ведётся обратная сверка дизайна
  на Шаге 8.
- `AGENTS.md`, `CLAUDE.md`, `README` — чтобы знать конвенции проекта.

**Жёсткое правило.** Если контракт (`OpenAPI`/`AsyncAPI`) отсутствует,
таблица отказов в `README` отсутствует, или компонентные сценарии
Gherkin для эндпоинтов будущих slice'ов не написаны — **проектирование
не начинается**. Opus останавливается, сообщает оператору, и предлагает
сначала зафиксировать недостающие артефакты как отдельную задачу.

Без контракта проектировать слайс не на чем: нет источника истины
о форме запроса, ответа и кодах ошибок. Без таблицы отказов
непонятно, какие компонентные сценарии отказа писать (см. [правило
различимости](https://codemonsters.team/blog/2026/04/28/component-tests-skill-application/)).
Без Gherkin-сценариев нечем сверить дизайн на полноту: opus может
спроектировать slice, который выглядит правильным по контракту,
но мимо ожиданий исполняемой спецификации (формат `error.code`
в ответе, заголовки, эффекты на интеграциях). Восстанавливать эти
артефакты по ходу проектирования = плодить расхождения между
контрактом, кодом и тестами. Только сначала зафиксированные контракт +
Gherkin, потом проектирование.

Если контракт есть, но в нём не описаны 5xx-ответы с `error.code`,
или таблица отказов пустая, или Gherkin-файлы существуют, но в них
нет сценариев на режимы отказа из таблицы — это тот же случай:
остановиться, зафиксировать недостающее с оператором, потом
продолжить.

### Шаг 1. Сформулировать задачу одной фразой

Если в одну фразу не получается — задача слишком крупная. Резать на под-задачи
или переключиться на её часть. Зафиксировать формулировку в `docs/intent/<slug>.md`.

### Шаг 2. Перечислить входы slice'ов

Один внешний вход = один vertical slice. Тип входа определяется
интеграцией:

- **HTTP-эндпоинт** — для синхронных API.
- **Топик/очередь брокера** — для асинхронных событий.
- **gRPC-метод** — для типизированных синхронных вызовов.
- **CLI / cron / файловый триггер** — для пакетных и фоновых задач.

Если входа ещё нет в контракте (`OpenAPI` для синхронных, `AsyncAPI`
для асинхронных) — спроектировать с оператором. Параметры,
которые надо зафиксировать, зависят от типа: для HTTP — метод, путь,
авторизация, идемпотентность; для брокера — топик/очередь, схема
сообщения, поведение DLQ; для gRPC — метод и proto-схема.

Зафиксировать таблицу:

| # | Тип входа | Идентификатор | Slice (имя) | Краткое описание |
|---|-----------|---------------|-------------|------------------|

Где «Тип входа» — `HTTP` / `Broker` / `gRPC` / `CLI`, а
«Идентификатор» — `POST /v1/registrations` для HTTP,
`registrations.created` (топик) для Broker, `RegistrationService.Create`
для gRPC, `registrations:cleanup` для cron.

### Шаг 3. Для каждого slice'а спроектировать дерево модулей

Сверху вниз, нисходящим способом. Один slice — одно дерево. Структура slice'а
обязательно включает:

- **ингресс-адаптер****только парсинг**: внешнее представление
  → типизированный `Request`. Конкретная форма зависит от типа входа
  slice'а (HTTP / Broker / gRPC / CLI — см. Шаг 2). Никакой бизнес-валидации.
- **головной модуль slice'а** — оркестратор: вызывает конструктор
  доменной команды (валидация), описывает пайп исполнения, вызывает
  модули логики и I/O, возвращает результат.
- модули логики — **конструкторы доменных структур** и чистые функции
  над ними (листья дерева). Вся валидация — здесь, через конструкторы
  типа `NewT(raw) -> (T, error)`. Невалидные данные → структура
  не собирается, конструктор возвращает ошибку.
- модуль I/O slice'а (запись/чтение БД, публикация события, вызов
  внешнего API).

Схема:

```
ингресс-адаптер (только парсинг)
     |
     v
головной модуль slice'а (оркестратор)
     |
     +--> конструкторы доменных структур (валидация)
     +--> модули логики над валидированными структурами
     +--> модуль I/O
```

Каждый узел — модуль с **одним входом и одним выходом**.
На каждом узле — фраза «что делает», в одно предложение.

#### Жёсткое правило одного аргумента (data vs deps)

«Один вход» в дисциплине трактуется буквально: **каждый узел дерева
принимает ровно одну `data`-сущность** на вход — либо доменную структуру
(`Command`, `Entity`, `RegistrationSession`), либо `Request` DTO
из ингресс-адаптера, либо ничего (для модулей-генераторов).

Зависимости (`deps`) — `*sql.DB`, клиент брокера, `clock.Clock`, конфиг
(`RPConfig`, `JWTConfig`), логгер — это **не data**. Они инжектятся
сбоку (через DI / receiver / closure / контекст), и в спецификации
объявляются отдельной строкой `Dependencies:` (см. Шаг 5).

**Алгоритм проверки.** Для каждого узла дерева посчитать число
data-аргументов (всё, что не deps):

- 0 или 1 — модуль контрактуется, идём дальше.
- 2 и больше — **стоп**. Завести доменную сущность, которая объединит
  эти аргументы, и добавить **отдельный узел-конструктор**
  (`NewT(...)`) для её сборки выше по пайпу. Пересчитать.

См. раздел [«Граница адаптера и логики»](#adapter-vs-logic) выше —
там же лежит развёрнутый пример «как НЕ надо» с пятью протекающими
полями и «как надо» с введённой доменной сущностью.

#### Жёсткое правило проверки инвариантов: подтип, не guard

Если в **логическом** шаге пайпа появляется сигнатура
`имя(вход: Domain) -> Result<(), Error>` (или `(input) -> error` в Go),
и единственная цель шага — отбраковать вход с ошибкой, **это сигнал**.
Такой шаг — guard. Инвариант не закреплён в типе: после `checkX(entity)`
структура `entity` не изменилась, и любой другой код может принять её
без проверки.

Это правило **не** относится к I/O-модулям с эффект-сигнатурой
`Result<(), Error>` — публикация события, удаление записи, write-tx
без возврата ID. У них нет «полезного выхода», который можно было бы
закодировать в тип; они трубы.

**Как чинить.** Завести подтип, несущий инвариант в типе. Заменить guard
на конструктор подтипа. Развёрнутый пример с `FreshSession` лежит
в разделе [«Конструктор подтипа вместо guard-функции»](#adapter-vs-logic) выше.

Подтип регистрируется в `messages.md` рядом с базовым типом. Эффект
на формулу юнит-тестов: конструктор подтипа считается по той же формуле
`1 happy + Σ ветки антецедента`. Никакого дополнительного покрытия
не требуется — наоборот, исчезает строка под guard-функцию.

#### Псевдокод пайпа головного модуля

Головной модуль slice'а должен читаться как «конспект работы slice'а» —
видна вся последовательность шагов за один взгляд.

**Форма головного модуля slice'а — линейный пайп исполнения.** Пять-десять
шагов, каждый шаг — отдельный модуль из дерева, поток данных идёт через
`Result<T, Error>` (или языковой эквивалент: `(T, error)` в Go,
`Mono<Result<T>>` в Kotlin/Reactor, `?`-оператор в Rust). Никаких
вложенных условий и циклов в самом пайпе. См. раздел [«Головной модуль
slice'а — пайп исполнения»](#slice-head-pipe) с примерами.

В карточке slice'а opus обязательно фиксирует **псевдокод пайпа**,
например:

```
processRegistration(req: Request) -> Result<RegistrationResponse, Error>:
    | NewRegistrationCommand(req)        -> RegistrationCommand
    | persistChallenge(cmd, store)       -> ChallengeID
    | buildResponse(cmd, challengeID)    -> RegistrationResponse
```

Этот псевдокод — главный артефакт карточки slice'а: по нему sonnet
напрямую пишет тело головного модуля.

#### Головной модуль — оркестратор-труба, не тестируется юнитами

Головной модуль **прост как труба**: каждый шаг вызывает ровно один
дочерний модуль и передаёт результат следующему. Никакой собственной
логики — только линейная последовательность вызовов. Именно поэтому
его псевдокод читается за одну минуту.

**Ошибки I/O пробрасываются через пайп без трансформации.** Если шаг
`persistChallenge` вернул `ErrDBLocked` — пайп прерывается, ошибка
поднимается к ингресс-адаптеру, который маппит её в HTTP 503.
Головной модуль не «разбирает» ошибки I/O — он их только пробрасывает.
Разбор кодов ошибок принадлежит ингресс-адаптеру и описывается в
карточке как маппинг `ErrXxx → HTTP-статус`.

**Следствие для тестов.** Юнит-тест головного модуля — интеграционный
тест (пайп собирает реальные зависимости). Его **не проектируют** и
**не пишут**. Корректность пайпа доказывает компонентный сценарий через
реальный вход slice'а. Ветки ошибок I/O покрываются сценариями отказа
(`db_locked`, `db_disk_full` и т.д.) — не юнит-тестами.

**Следствие для Deps.** В `Deps` головного модуля нет полей, которые
нужны только ради подмены в тесте (`Rand io.Reader`, `Persist func(...)`,
`Now func() time.Time` — если только это не clock.Clock-инъекция
для детерминированного времени). Если поле в `Deps` нужно только чтобы
подставить заглушку в тест — это сигнал попытки юнит-тестировать head.
Такое поле не вводить. Реальную зависимость захардкодить внутри функции.

### Шаг 4. Описать каталог сообщений

Все структуры данных, которыми обмениваются модули внутри slice'а:

- `Request` — невалидированный вход из адаптера. Поля публичные,
  без правил домена.
- `Command` / `Entity` / `DTO` — валидированный объект предметной области.
  **Поля неэкспортируемые. Создаётся только через конструктор**
  `NewT(...) -> (T, error)`, который проверяет правила домена.
  Если правила не выполнены — структура не создаётся.
- `Event` — факт для брокера/наблюдателей.
- `Error` — описание провала.
- `Result<T, Error>` — результат: успех с T или ошибка.

**Правило сигнатур:** в `Result` всегда указываем оба типа-параметра:
`Result<Client, Error>`, не `Result<Client>`.

**Приписка для Go.** В Go идиоматический эквивалент `Result<T, Error>`
— пара возвратов `(T, error)`. Везде, где в спецификации стоит
`Result<T, Error>`, в Go-коде это означает функцию, возвращающую
`(T, error)`. Семантика та же: успех с T или ошибка. Дженерик-тип
`Result[T any]` в Go-проектах не вводим — это ломает идиому языка
и не даёт ничего сверх стандартной пары.

### Шаг 5. Описать контракты модулей

Для каждого модуля slice'а — **жёсткий шаблон контракта**:

```
### <ИмяМодуля>

- **Сигнатура:** `имя(input: Type) -> Result<выход: Type, Error>`
- **Input (data):** одна доменная структура, или Request DTO, или void.
                    Если data-аргументов 2+ — это нарушение Шага 3
                    «жёсткого правила одного аргумента»: возврат на Шаг 3.
- **Dependencies (deps):** `*sql.DB`, `broker.Client`, `clock.Clock`,
                            `*Logger`, конфиг (`RPConfig`, `JWTConfig`).
                            Если deps нет — пишем `—`.
- **Что делает:** одна фраза.
- **Антецедент:** условия на input.
- **Консеквент:**
  - Success: что гарантирует на выходе.
  - Failure: классы ошибок (`ErrXxx`, `ErrYyy`).
```

Это поле-в-поле зафиксированный шаблон. Нет «разговорного» описания
сигнатуры — Input и Dependencies всегда отдельными строками. Это
страхует от соскальзывания обратно в плоский список аргументов.

#### Жёсткий чек-лист `Dependencies:` (защита от ошибки сырого I/O)

При заполнении строки `Dependencies:` каждого контракта — **обязательная
механическая сверка** с таблицей интеграций (Шаг 6). Если в зависимостях
появляется сырой клиент внешнего мира — это нарушение Шага 6 ровно той
же силы, что нарушение «один data-аргумент» в Шаге 3: возврат к Шагу 3,
ввести автономный I/O-объект (`Store`/`Client`/`Publisher`/`Consumer`),
сделать модуль его методом, в `Dependencies:` поставить `—`.

Запрещённые значения в `Dependencies:` (signal of unwrapped I/O):

| Запрещено              | Тип интеграции   | Что должно быть вместо  |
|------------------------|------------------|--------------------------|
| `*sql.DB`, `*sql.Tx`   | База данных      | `—` (метод объекта `Store`) |
| `*http.Client`, базовый URL | Внешний HTTP API | `—` (метод объекта `Client`) |
| Соединение брокера, продюсер/консьюмер брокера | Брокер сообщений | `—` (метод объекта `Publisher`/`Consumer`) |
| `*os.File`, `io.Writer` файла | Файловая система | `—` (метод объекта `FileStore`) |

Разрешённые значения в `Dependencies:` (это конфиг или ortogonal-инструменты,
не интеграции):

- `RPConfig`, `JWTConfig`, любые value-конфиги — это not I/O.
- `clock.Clock` — детерминированное время, не интеграция.
- `*slog.Logger` — observability, не интеграция.
- `io.Reader` для энтропии (`crypto/rand.Reader`) — пограничный случай;
  допустим в логических модулях ради тестируемости, но **не** для I/O.
  В голове `Deps` — обычно не нужен.

**Алгоритм проверки.** После заполнения каждого контракта — пройти
по строке `Dependencies:` каждого модуля и сверить со столбцом «Запрещено».
Хоть одно совпадение — стоп: возврат к Шагу 3, ввести I/O-объект, сделать
этот модуль его методом. Это механический чек, а не творческое решение —
он либо проходит, либо нет.

Цена пропуска проверки — на следующих слайсах оператор находит сырой
`*sql.DB` в дизайне и просит переделать. Чек-лист добавлен именно
ради того, чтобы не повторилось.

Уточнения:

- **I/O-модули без полезного выхода** (опубликовать событие, удалить
  запись): сигнатура — `Result<(), Error>` (или `error` в Go).
  Полезной нагрузки в успехе нет, но успех/провал по контракту
  различается явно.
- **Если консеквент не удаётся обосновать** — модуль спроектирован
  неправильно, проектируй дальше.
- **Если Input не помещается в одну доменную структуру** — это
  сигнал, что либо нужна новая доменная сущность (вернуться к Шагу 3
  и добавить узел-конструктор), либо модуль делает слишком много
  и его пора резать.

### Шаг 6. Изолировать I/O

В каждом slice'е **вся работа с внешним миром собрана в I/O-модулях**
(`*_io.go`, `*Repository`, `*Gateway`, `*Adapter`). Бизнес-логика slice'а —
чистые функции, никакого HTTP / БД / брокера / файловой системы.

**I/O-модулей в slice'е может быть несколько** — это нормально. Реальный
slice часто делает: «получить запрос → достать состояние из БД →
вызвать внешний REST → записать результат в БД → опубликовать событие».
Здесь четыре разных I/O, каждый — отдельный модуль с собственным
контрактом и режимом отказа. Это не повод дробить slice — это
естественная сложность бизнес-операции.

Признак, что slice пора дробить — **не количество I/O-модулей, а
количество независимых use case'ов** в одном slice'е. Если в одном
slice'е сосуществуют «зарегистрировать пользователя» и «запустить
отчёт по администраторам» — это два slice'а, не один с двумя I/O.

Что **должно** быть в одном I/O-модуле:

- одна внешняя зависимость (одна БД, один брокер, один внешний сервис);
- один режим работы с этой зависимостью (чтение или запись, не «чтение
  и запись подряд» внутри одного модуля).

Почему это правильно: каждый I/O-модуль проверяется ровно одним
сценарием отказа в компонентных тестах. Если в один модуль запихнуть
два режима работы — отказы перемешаются и сценарии компонентных тестов
перестанут быть различимыми.

#### Правило автономного IO-объекта

Каждый I/O-модуль проектируется как **автономный объект**, инкапсулирующий
свою зависимость. Головной модуль знает только методы объекта (API),
не его внутренние зависимости. См. таблицу типов и развёрнутый пример
выше в разделе [«Автономный I/O-объект»](#adapter-vs-logic).

В контракте (Шаг 5) строки I/O-модуля:

- `Input (data):` — одно доменное сообщение;
- `Dependencies:``—` (зависимость инкапсулирована в объект,
  головной модуль её не видит);
- в описании `Deps` головного модуля — поле типа `Store` / `Client` /
  `Publisher`, **не** сырая зависимость (`*sql.DB`, `*http.Client`…).

Признак нарушения при проверке дизайна: сырая зависимость (`*sql.DB`,
`*http.Client`) в строке `Dependencies:` контракта или в `Deps`
head-модуля. Это значит — IO-объект не введён. Стоп, вернуться к Шагу 3.

#### Правило пустой трубы IO-модуля

IO-модуль не содержит бизнес-логики. Каждый метод объекта — труба:
взять доменное сообщение → вызвать внешнюю систему → вернуть результат
или ошибку. Никаких условных ветвлений по данным, никаких трансформаций.
Единственное допустимое ветвление — маппинг кодов ошибок внешней
системы на доменные ошибки (`SQLITE_BUSY → ErrDBLocked`).

IO-модули юнит-тестами **не покрываются**: success-ветка зеленит
happy-path компонентный сценарий, failure-ветки — сценарии отказа.

### Шаг 7. Описать инфраструктурный модуль приложения

Инфраструктурный модуль — один на всю программу, технический корень.
Состав зависит от того, какие типы входов есть в сервисе (см. Шаг 2):

- инициализирует общие зависимости (пул БД, клиент брокера, логгер,
  конфигурацию);
- если есть HTTP-slice'ы — поднимает HTTP-сервер и регистрирует роуты,
  каждый ведёт к ингресс-адаптеру своего slice'а;
- если есть Broker-slice'ы — поднимает потребителя брокера и подписывает
  ингресс-адаптеры на свои топики/очереди;
- если есть gRPC-slice'ы — поднимает gRPC-сервер и регистрирует
  ингресс-адаптеры как handler'ы своих методов;
- если есть CLI/cron-slice'ы — регистрирует точки входа в планировщике
  или CLI-роутере;
- передаёт slice'у инициализированные зависимости через DI / параметры.

В этом модуле **нет бизнес-логики**, ни одной строки. Его задача — собрать
программу из готовых slice'ов и поднять. Никакой оркестрации между
slice'ами — она невозможна по построению, потому что slice'ы независимы.

Тестируется этот модуль не юнитами (нечего тестировать в чистом виде),
а компонентными тестами, которые проверяют каждый slice через его
реальный вход — HTTP-запрос для HTTP-slice'а, публикацию сообщения
в брокер для Broker-slice'а, gRPC-вызов для gRPC-slice'а.

Не путать с **головным модулем slice'а** (см. Шаг 3): тот — модуль логики,
оркестратор пайпа конкретного среза, и пишется на каждый slice свой.

### Шаг 8. Спроектировать тесты и сверить дизайн с Gherkin-сценариями

Шаг состоит из двух частей: посчитать юнит-тесты по формуле и
**обратно сверить** дизайн slice'а с уже написанными Gherkin-
сценариями (см. Шаг 0 — они обязательны на входе).

#### 8.1. Юнит-тесты модулей логики

Для каждого модуля **логики** (конструкторы доменных структур и чистые
функции над ними):

```
N_юнит_тестов = 1 (happy path) + Σ (ветки антецедента)
```

**Жёсткое правило: головной модуль, I/O-модули и ингресс-адаптер
юнитами не покрываются.**

Головной модуль — **оркестратор-труба** из уже протестированных частей.
Юнит-тест над ним был бы интеграционным тестом (пайп собирает реальные
зависимости). Его корректность и все ветки ошибок I/O доказываются
компонентными сценариями через реальный вход slice'а (см. Шаг 3,
«Головной модуль — оркестратор-труба»).

I/O-модули по сути **трубы** — переносят байты между процессом и внешней
зависимостью (БД, брокер, внешний API). Бизнес-логики нет, тестировать
нечего. «Юнит-тест» против `:memory:` БД — маленький интеграционный
тест, а не юнит.

Ингресс-адаптер: парсит вход, маппит ошибки в формат ответа — нет
алгоритма, который надо проверять юнитом.

Что проверяет **что**:

| Артефакт                          | Юнит-тест                                | Компонентный (Gherkin)                                                  |
|-----------------------------------|------------------------------------------|--------------------------------------------------------------------------|
| Конструктор доменной структуры    | да, по формуле                            | косвенно, через happy path                                               |
| Чистая функция логики             | да, по формуле                            | косвенно, через happy path                                               |
| **Головной модуль слайса**        | **нет** (труба; юнит = интеграционный тест) | **да**, happy path + все ветки ошибок I/O через сценарии отказа        |
| **I/O-модуль (Success-ветка)**    | **нет**                                   | **happy-path сценарий слайса** (если запись не дойдёт — Gherkin красный) |
| **I/O-модуль (Failure-ветки)**    | **нет**                                   | **сценарий отказа** того слайса, к которому режим отказа привязан правилом различимости |
| **Ингресс-адаптер (парсинг)**     | **нет**                                   | **happy + сценарии ошибок** (через реальный HTTP-вход)                  |

#### 8.2. Антипример — как не надо

```
| Модуль                       | Happy | Ветки                | Итого |
|------------------------------|-------|----------------------|-------|
| persistRegistrationSession   | 1     | дубликат UUID (UNIQUE) | 2   |   ← НЕЛЬЗЯ
```

I/O в таблице юнит-тестов — стоп, удалить строку. Дубликат UUID —
поведение SQLite, не антецедент конструктора, проверяется компонентным
сценарием, либо принимается как невозможный по построению (UUID v4
из `crypto/rand` не коллизионируется).

#### 8.3. Компонентные сценарии — уже написаны

Для slice'а в целом компонентный тест в Gherkin **уже существует**
к моменту Шага 8 (Шаг 0 это гарантирует):

- 1 happy path сценарий;
- по сценарию на каждый различимый режим отказа I/O-модулей slice'а
  (см. правило различимости в [скилле компонентных тестов](https://codemonsters.team/blog/2026/04/26/component-tests-skill-passkey-demo/)).

Opus их не пишет — он использует их как источник истины.

#### 8.4. Таблица сверки Gherkin ↔ модули slice'а

Это главный артефакт Шага 8. Цель — для **каждого** Then-шага
**каждого** Gherkin-сценария slice'а явно указать узел графа вызовов
(см. Шаг 9), который этот Then зеленит. Если Then-шаг не привязывается
ни к одному узлу — дизайн slice'а неполон, возврат к Шагу 3.

Таблица кладётся в карточку slice'а (`docs/design/<slug>/slices/<n>-<name>.md`),
раздел `## Gherkin-mapping`. Формат:

| Сценарий                         | Then-шаг                                          | Кто обеспечивает (узел графа / маппинг адаптера) |
|----------------------------------|---------------------------------------------------|--------------------------------------------------|
| happy: успешная регистрация      | ответ 201 + `challenge`                            | головной → `buildResponse`                       |
| happy: успешная регистрация      | challenge сохранён в БД                            | I/O `persistChallenge` (Success-ветка)           |
| happy: успешная регистрация      | событие `registration_started` опубликовано        | I/O `publishRegistrationStarted` (Success-ветка) |
| db_locked: SQLITE_BUSY           | ответ 503 + `Retry-After` + `error.code=db_locked` | I/O `persistChallenge` (Failure: ErrDBLocked) → ингресс-адаптер: маппинг ErrDBLocked → 503 |
| db_locked: SQLITE_BUSY           | challenge **не** сохранён                          | предусловие к I/O `persistChallenge` (атомарность транзакции) |
| db_disk_full                     | ответ 507 + `error.code=db_disk_full`              | I/O `persistChallenge` (Failure: ErrDiskFull) → ингресс-адаптер: маппинг ErrDiskFull → 507 |

Один Then-шаг — одна строка таблицы. Если один Then стоит в нескольких
сценариях — повторить строку (не сворачивать), чтобы при правке одного
сценария не задеть другой.

#### 8.5. Чек-лист сверки

Для каждой строки таблицы проверить:

1. **Узел существует.** Указанный модуль/маппинг описан в Шаге 5 (контракты)
   и появится в графе на Шаге 9.
2. **Ветка соответствует.** Если Then ожидает ошибку — узел должен иметь
   соответствующий Failure-путь с тем же классом ошибки. Если Then ожидает
   эффект на интеграции — узел должен быть I/O-модулем с тем эффектом.
3. **Формат ответа адаптера согласован.** Если Then проверяет HTTP-код,
   заголовок (`Retry-After`), `error.code` в теле — в карточке slice'а
   зафиксирован маппинг класса ошибки в этот формат, либо ингресс-адаптер
   делегирует это общему хелперу из `infrastructure.md`.
4. **Все Then покрыты.** Прошёлся по всем Gherkin-сценариям slice'а —
   ни одна строка из `.feature` не осталась без записи в таблице.

Если на любом пункте расхождение — **возврат к Шагу 3** (дерево модулей)
или **Шагу 5** (контракты), правка, повторный прогон 8.4–8.5.

Шаг 8 считается выполненным, когда таблица заполнена, все четыре пункта
чек-листа закрыты, и в карточке slice'а явно стоит `[x] Gherkin-mapping
сверен`.

### Шаг 9. Сверить согласованность контрактов всех модулей

К этому моменту описаны все модули, все сообщения, все сигнатуры.
Прежде чем складывать пакет проектной документации — обязательная сверка:
**ни один модуль не должен ссылаться на структуру или сигнатуру, которых
не существует**, и **ни один консеквент модуля A не должен противоречить
антецеденту модуля B**, который A вызывает.

Без этого шага sonnet наткнётся на расхождение в момент компиляции
(структура не та) или в момент тестирования (антецедент конструктора
не выполняется потому, что предыдущий модуль вернул что-то другое).
Дешевле найти расхождение на бумаге.

Сверка делается через **граф вызовов модулей slice'а**. Один граф на
slice + один общий по каталогу сообщений.

#### 9.1. Каталог сообщений: транзитивная замкнутость

Пройти `messages.md` и проверить:

- каждое поле каждой структуры имеет объявленный тип;
- если тип — другая структура из каталога, она тоже описана;
- если тип — конструктор-валидируемый (`Email`, `Handle`, `BirthDate`),
  у него явно описан конструктор `NewT(...) -> (T, error)`.

Никаких «потом доопределим» и `TODO: уточнить тип` в каталоге.

#### 9.2. Граф вызовов slice'а

Для каждого slice'а нарисовать (ASCII или mermaid) граф: какой модуль
кого вызывает и что передаёт. Стрелка несёт **имя структуры**, а не
неформальное описание. **Каждый модуль slice'а — отдельный узел графа**;
не сворачивать «все конструкторы» или «все I/O» в один прямоугольник —
теряется возможность сверки.

Пример (slice регистрации):

```
ингресс-адаптер (HTTP / Broker / gRPC / CLI)
   |
   | parses to: Request
   v
головной модуль slice'а (processRegistration)
   |
   |-- (1) NewRegistrationCommand(Request) -> (RegistrationCommand, error)
   |       вызывает конструкторы: NewHandle, NewEmail, NewBirthDate
   |
   |-- (2) loadExistingUser(handle Handle, db) -> (Maybe<User>, error)
   |       I/O #1: чтение из БД
   |
   |-- (3) buildChallenge(cmd RegistrationCommand) -> Challenge
   |       чистая функция логики
   |
   |-- (4) persistChallenge(challenge Challenge, db) -> error
   |       I/O #2: запись в БД
   |
   |-- (5) publishRegistrationStarted(challenge Challenge, broker) -> error
   |       I/O #3: публикация в брокер
   |
   |-- (6) buildResponse(challenge Challenge) -> RegistrationResponse
   |       чистая функция логики
   v
ингресс-адаптер (форматирует RegistrationResponse в HTTP/Broker/gRPC ответ)
```

Видно: конструктор валидации (1), три I/O-модуля (2, 4, 5), две чистые
функции логики (3, 6). По графу сразу понятно, какие интеграции
у slice'а и в каком порядке они вызываются.

#### 9.3. Чек-лист сверки

Для каждой стрелки графа проверить **шесть пунктов**:

1. **Тип на стрелке существует** в `messages.md` или в стандартной
   библиотеке языка.
2. **Имя сигнатуры на стрелке совпадает** с тем, что записано в карточке
   модуля-получателя. Не «createRegistration», в одном месте «registerUser»
   в другом.
3. **Консеквент отправителя ⊆ антецеденту получателя.** То, что модуль A
   гарантирует на выходе, должно полностью удовлетворять тому, что модуль B
   требует на входе. Если A гарантирует «email непустой», а B требует
   «email непустой и подтверждённый» — расхождение, B будет падать.
4. **Тип ошибки согласован.** Если A может вернуть `ErrEmailInvalid`,
   а B этот класс ошибок не разбирает — расхождение, ошибка протечёт
   мимо обработчика.
5. **Покрытие Gherkin-сценариев.** Каждый Then-шаг каждого Gherkin-
   сценария slice'а ложится на конкретный узел графа или маппинг
   в ингресс-адаптере (таблица из Шага 8.4). Если Then не находит
   узла — расхождение между исполняемой спецификацией и дизайном.
   Если узел графа не упомянут ни одним Then — узел кандидат на
   удаление (мёртвая логика), либо в Gherkin не хватает сценария.
   В обоих случаях — возврат к Шагу 3 или Шагу 5, не «починим в
   реализации».
6. **Один data-аргумент на узел.** На стрелке-входе каждого узла —
   ровно одна доменная структура / DTO / void. Если стрелок-входов
   несколько (узел получает 2+ data-аргументов) — нарушение Шага 3
   «жёсткого правила одного аргумента»: возврат на Шаг 3, ввести
   доменную сущность и узел-конструктор. Зависимости (`*sql.DB`,
   `clock.Clock`, конфиг) на стрелках графа **не** показываются —
   они в `Dependencies:` контракта модуля.

#### 9.4. Зафиксировать сверку

Результат сверки кладётся в `docs/design/<slug>/contracts-graph.md`:

- ASCII или mermaid-граф каждого slice'а;
- таблица стрелок: «кто вызывает», «кого вызывает», «что передаёт»,
  «что получает обратно», «классы ошибок»;
- явная отметка `[x] согласовано` под каждым slice'ом.

Если на каком-то пункте чек-листа возникает расхождение — **возвращаемся
к Шагу 5** (контракты модулей) и правим. Не «поправим в реализации» —
правим в спецификации, потом перепрогоняем 9.1–9.3.

Шаг считается выполненным, когда все стрелки всех графов помечены
`[x] согласовано` и `contracts-graph.md` зафиксирован.

### Шаг 10. Собрать пакет проектной документации

Финальный артефакт opus'а — папка `docs/design/<slug>/`:

```
docs/design/<slug>/
├── intent.md              # одна фраза + контекст
├── slices.md              # таблица срезов
├── messages.md            # каталог сообщений с типами
├── slices/
│   ├── 01-<slice>.md      # дерево модулей (адаптер → головной → логика → I/O)
│   │                      #   + контракты + антецеденты/консеквенты + тесты
│   ├── 02-<slice>.md
│   └── ...
├── infrastructure.md      # инфраструктурный модуль приложения:
│                          #   HTTP-сервер / потребитель брокера /
│                          #   gRPC-сервер / cron — в зависимости
│                          #   от типов входов slice'ов
├── contracts-graph.md     # граф вызовов модулей + сверка согласованности
│                          #   (см. Шаг 9)
└── backlog.md             # тикеты для sonnet (см. ниже)
```

### Шаг 11. Сформировать бэклог тикетов

Один тикет = один slice.

Шаблон тикета:

```
TICKET S<n> — slice <name>: <идентификатор входа>

(<идентификатор входа> — например `HTTP POST /v1/registrations`,
 `Broker registrations.created`, `gRPC RegistrationService.Create`,
 `CLI registrations:cleanup`)

Спецификация:
  docs/design/<slug>/slices/<n>-<name>.md

Зависимости (предыдущие тикеты, должны быть в main):
  - S<m>, S<k>

Ветка: feat/slice-<name>

Definition of Done:
  - [ ] ингресс-адаптер реализован: парсит внешний вход в Request, без бизнес-валидации (для HTTP — JSON+headers, для Broker — payload+properties, для gRPC — proto-сообщение, для CLI — args)
  - [ ] конструкторы доменных структур реализованы: проверяют антецедент, при невалидных данных возвращают ошибку (структура не создаётся)
  - [ ] модули логики реализованы, контракты выполнены
  - [ ] модуль I/O изолирует все внешние вызовы
  - [ ] головной модуль slice'а реализован: оркестрирует пайп, разбирает ошибки конструкторов и I/O
  - [ ] slice подключён к своему типу входа в инфраструктурном модуле (роут / подписка / handler / расписание)
  - [ ] юнит-тесты по формуле, покрытие 100% по строкам и веткам логики
  - [ ] компонентный тест Gherkin зелёный для happy path (через реальный вход slice'а)
  - [ ] компонентные тесты режимов отказа зелёные
  - [ ] локальный CI зелёный
  - [ ] PR создан, описание заполнено по шаблону
  - [ ] PR смержен в main, CI на main зелёный
```

### Шаг 12. Заполнить хендофф-чеклист

Последний шаг перед передачей sonnet'у. Чеклист кладётся в начало
`docs/design/<slug>/backlog.md` (раздел `## Хендофф`). Opus заполняет
галочки `[x]` сам, проверяя, что соответствующий артефакт реально
существует и содержит то, что требуется.

**Формат отметки об аппруве оператора.** Последняя строка чеклиста
заполняется оператором (не opus'ом!) в строгом формате:

```
- [x] Оператор аппрувит пакет — @<github-handle>, <YYYY-MM-DD>
```

Например: `- [x] Оператор аппрувит пакет — @maxmorev, 2026-05-02`.

Эта строка — единственный детерминированный признак, по которому
sonnet распознаёт «пакет принят». Без неё (или если стоит `[ ]`)
sonnet к работе не приступает.

Без полного аппрува хендоффа sonnet к работе не приступает.

```
## Хендофф-чеклист (заполняет opus, проверяет оператор)

- [ ] OpenAPI / AsyncAPI зафиксирован, все эндпоинты slice'ов в нём описаны
- [ ] OpenAPI / AsyncAPI содержит 5xx-ответы с `error.code` для каждого режима отказа
- [ ] README содержит таблицу «Карта режимов отказа» (HTTP-статус / тип события / заголовки, действие клиента, действие оператора)
- [ ] **Компонентные сценарии Gherkin для эндпоинтов всех slice'ов написаны, закоммичены, стабильны (один happy + сценарий на каждый различимый режим отказа)**
- [ ] Папка docs/design/<slug>/ создана и полна
- [ ] intent.md — задача в одну фразу
- [ ] slices.md — таблица срезов с типом входа, идентификатором, назначением
- [ ] messages.md — все структуры данных и Result<T, Error>
- [ ] Для каждого slice'а есть отдельный файл с деревом модулей
- [ ] У каждого slice'а описан головной модуль (оркестратор пайпа)
- [ ] У головного модуля каждого slice'а зафиксирован псевдокод пайпа исполнения (5–10 шагов)
- [ ] У каждого модуля логики описаны антецедент и консеквент
- [ ] У каждого I/O-модуля slice'а описан контракт и режимы отказа
- [ ] **У каждого модуля Input — одна доменная структура / DTO / void; deps вынесены отдельной строкой `Dependencies:` (Шаг 5). Узлов с 2+ data-аргументами в графе нет**
- [ ] **I/O-зависимости (БД, HTTP, брокер, файловая система) инкапсулированы в автономный объект `Store`/`Client`/`Publisher`/`Consumer`/`FileStore` (Шаг 6). Сырых `*sql.DB`, `*http.Client`, broker-conn в `Dependencies:` контрактов модулей и в `Deps` головного модуля нет — они скрыты внутри I/O-объекта (Шаг 5, чек-лист `Dependencies:`)**
- [ ] **Карточка каждого slice'а содержит таблицу `## Gherkin-mapping`: каждый Then-шаг каждого сценария slice'а привязан к узлу графа или маппингу адаптера (Шаг 8.4)**
- [ ] **contracts-graph.md существует, граф каждого slice'а согласован (все стрелки помечены `[x]`, в т.ч. пункт 5 о покрытии Gherkin-сценариев)**
- [ ] Для конструкторов доменных структур и чистых функций логики посчитаны юнит-тесты по формуле
- [ ] **В таблице юнит-тестов каждой карточки слайса нет головного модуля, нет I/O-модулей и нет ингресс-адаптера: все три — трубы, проверяются только компонентными сценариями (Шаг 8.1)**
- [ ] infrastructure.md — описан инфраструктурный модуль приложения
- [ ] backlog.md — тикеты по одному на slice, с зависимостями
- [ ] Оператор аппрувит пакет — @<github-handle>, <YYYY-MM-DD>
```

## Definition of Done скилла

- Все 12 шагов пройдены.
- Папка `docs/design/<slug>/` создана и заполнена.
- `backlog.md` содержит тикеты по одному на slice.
- **Хендофф-чеклист в `backlog.md` полностью заполнен `[x]` и зааппрувлен оператором.**
- После аппрува — передача sonnet'у.

Этот скилл — не «инструкция вообще», это конкретный набор шагов с конкретными артефактами на каждом шаге. Если на каком-то шаге у opus'а нет данных — он останавливается и спрашивает оператора, не выдумывая ответы.

Скилл реализации для sonnet

Второй артефакт. Кладётся как skills/program-implementation/SKILL.md.

Главная задача этого скилла — прошить TBD-цикл и предохранить sonnet'а от выхода за рамки тикета.

# program-implementation.skill — Реализация программы по тикетам, через Trunk Based Development

## Назначение

Скилл для sonnet. На вход — пакет `docs/design/<slug>/` от opus'а.
На выход — реализация программы, влитая в main по тикетам.

Метод: TBD + один тикет = один slice = одна ветка = один PR.

## Зона ответственности

DO:
- Реализовывать ровно то, что в тикете.
- Идти TBD-циклом для каждого тикета.
- Останавливаться и сообщать, если спецификация неполная или
  обнаружено противоречие.

DON'T:
- Менять контракты модулей без согласования с оператором.
- Реализовывать сразу несколько slice'ов в одной ветке.
- Делать «попутные улучшения», не относящиеся к тикету.
- Принимать архитектурные решения. Если возникает развилка — стоп, спросить.

## Шаги одного тикета

### Шаг 0. Проверить хендофф (выполняется один раз, при старте работы над пакетом)

Прежде чем брать первый тикет — открыть `docs/design/<slug>/backlog.md`,
найти раздел `## Хендофф-чеклист`. Все пункты должны быть `[x]`,
включая **последнюю строку** в строгом формате:

```
- [x] Оператор аппрувит пакет — @<github-handle>, <YYYY-MM-DD>
```

Эта строка — единственный детерминированный признак, что пакет принят.
Без неё (или если стоит `[ ]`) — sonnet **не начинает работу**, даже
если все остальные пункты `[x]`.

- Если чеклист отсутствует — пакет не готов. Остановиться, сообщить
  оператору. Без чеклиста начинать нельзя: без согласованного
  `contracts-graph.md` и зафиксированных контрактов sonnet будет
  упираться в расхождения на каждом тикете.
- Если в чеклисте есть `[ ]` (включая последнюю строку об аппруве)
  — пакет частично готов. Остановиться, сообщить, какие пункты
  не закрыты. Ждать, пока opus и оператор закроют их.
- Если все `[x]` и последняя строка содержит handle и дату — продолжаем
  со Шага 1.

Этот шаг выполняется **один раз** на весь пакет, не на каждый тикет.

### Шаг 1. Подтянуть main и создать ветку

```bash
git checkout main
git pull --ff-only origin main
git checkout -b feat/slice-<name>
```

Главное правило: новая ветка всегда от свежего main. Никаких ответвлений
от вчерашних веток.

### Шаг 2. Прочитать спецификацию slice'а

- `docs/design/<slug>/slices/<n>-<name>.md` — дерево модулей и контракты;
  здесь же раздел **`## Gherkin-mapping`** — таблица сверки Then-шагов
  Gherkin-сценариев slice'а с узлами графа (Шаг 8.3 скилла opus'а)
- `docs/design/<slug>/messages.md` — структуры данных
- `docs/design/<slug>/contracts-graph.md`**граф вызовов и согласованность контрактов**
- `component-tests/features/*.feature` — компонентные сценарии slice'а
  (исполняемая спецификация); читаются вместе с таблицей `## Gherkin-mapping`
- `AGENTS.md`, `CLAUDE.md` — конвенции репозитория

`contracts-graph.md` — основной источник истины о том, **что чему передаётся**
между модулями slice'а. Если в карточке модуля написано одно, а в графе
вызовов — другое, прав граф (он прошёл сверку opus'а).

Таблица `## Gherkin-mapping` — карта от модулей к Then-шагам Gherkin.
Используется в Шаге 3 для прогона ровно тех сценариев, которые
зеленит реализованный модуль.

Если в спецификации обнаружено противоречие, недосказанность или
требование, нарушающее принципы дисциплины — **остановиться и сообщить**.
Не реализовывать «как-нибудь». Конкретные триггеры остановки:

- большой модуль, не помещающийся в одну фразу «что делает»;
- два I/O в одном модуле (одна внешняя зависимость + один режим работы
  на модуль — см. Шаг 6 скилла opus'а);
- отсутствие антецедента или консеквента у модуля логики;
- рассогласование контрактов с графом (`contracts-graph.md`);
- **модуль с 2+ data-аргументами на входе** (нарушение «жёсткого
  правила одного аргумента» — Шаг 3 скилла opus'а). Признак: в
  сигнатуре или в графе у узла больше одной стрелки-входа,
  не считая deps. Признак-приметка в шаблоне Шага 5: строка
  `Input (data):` содержит «и», «,» между сущностями. Это сигнал,
  что opus пропустил введение доменной сущности — возврат на его
  Шаг 3.

**Особое правило про расхождения с графом контрактов.** Если по ходу
реализации обнаружено, что сигнатура модуля по графу не стыкуется
с реальностью (возвращаемая структура не подходит вызывающему,
тип ошибки не разбирается, антецедент следующего модуля сильнее
консеквента предыдущего) — это **архитектурное расхождение**.
Не подгоняем код под граф ad hoc, не «починим в реализации» —
останавливаемся, сообщаем оператору, opus возвращается на свой
Шаг 9 и перепроверяет согласованность. После правки графа sonnet
продолжает.

### Шаг 3. Реализация по формуле

Восходящий порядок (от листьев дерева к корню):

1. Структуры сообщений: `Request` (публичные поля, без правил) и
   доменные структуры (`Command`, `Entity`) — **с неэкспортируемыми
   полями**.
2. **Конструкторы доменных структур**`NewT(raw) -> (T, error)`.
   Проверяют антецедент: невалидные данные → ошибка, структура не
   создаётся. Это точка валидации slice'а; никакой валидации больше
   нигде нет.
3. Модули логики slice'а — чистые функции над уже валидированными
   доменными структурами (листья → узлы выше).
4. Модули I/O slice'а (по одному на каждую внешнюю операцию: чтение БД,
   запись БД, публикация в брокер, вызов внешнего REST). Их может быть
   несколько — это нормально, см. Шаг 6 скилла opus.

   **Правило автономного IO-объекта.** Каждый I/O-модуль оборачивается
   в объект, который **инкапсулирует свою зависимость**. Головной модуль
   знает только методы объекта (API), не его зависимости. Имя объекта
   отражает тип интеграции:

   | Интеграция | Имя объекта | Зависимость |
   |---|---|---|
   | База данных | `Store` | `*sql.DB` |
   | Внешний HTTP API | `Client` | `*http.Client` + baseURL |
   | Брокер сообщений | `Publisher` / `Consumer` | соединение брокера |

   Форма реализации (на примере Store):

   ```go
   // io.go
   type Store struct{ db *sql.DB }
   func NewStore(db *sql.DB) Store { return Store{db: db} }
   func (s Store) Save(msg DomainMessage) error { ... }

   // register.go — Deps содержит объект, а не его зависимость
   type Deps struct {
       Store  Store   // или Client, Publisher — по типу интеграции
       Clock  clock.Clock
       // ...
   }

   // wire.go или NewDeps — зависимость скрывается здесь
   func NewDeps(db *sql.DB, ...) Deps {
       return Deps{Store: NewStore(db), ...}
   }
   ```

   Признак нарушения: сырая зависимость (`*sql.DB`, `*http.Client`)
   в `head.go` или в `Deps` напрямую. Если видишь — исправить до коммита.

   **Правило пустой трубы.** IO-модуль не содержит бизнес-логики.
   Внутри метода: взять доменное сообщение → вызвать внешнюю систему →
   вернуть результат или ошибку. Никаких условных ветвлений по данным,
   никаких трансформаций. Единственное допустимое ветвление —
   маппинг кодов ошибок внешней системы на доменные ошибки
   (`SQLITE_BUSY → ErrDBLocked`). Вся бизнес-логика живёт в модулях
   логики (шаг 3), не здесь. IO-модули тестируются только компонентными
   тестами — юнит-тесты на них не пишутся.

5. **Головной модуль slice'а** — оркестратор: вызывает конструктор
   доменной команды (получает либо команду, либо ошибку), описывает
   пайп исполнения, вызывает модули логики и I/O.
6. **Ингресс-адаптер slice'а** — только парсинг: внешний вход → `Request`.
   Никакой бизнес-валидации. Конкретная форма: HTTP handler / Broker
   listener / gRPC handler / CLI command — берётся из карточки slice'а.
7. **Подключение slice'а к его типу входа в инфраструктурном модуле
   приложения**: регистрация HTTP-роута, подписка на топик/очередь
   брокера, регистрация gRPC-handler'а или CLI-команды. Один на всю
   программу, не путать с головным модулем slice'а.

На конструкторах и модулях логики (шаги 2–3):

- сначала юнит-тесты по формуле (happy + ветки антецедента);
- затем реализация, до зелёных юнит-тестов;
- никаких лишних веток, не описанных в контракте.

Головной модуль (шаг 5), ингресс-адаптер (шаг 6) и I/O-модули (шаг 4)
юнит-тестами **не покрываются**. Головной модуль — склейка уже
протестированных частей; его корректность доказывается компонентным
сценарием через реальный вход. I/O и ингресс-адаптер — трубы,
проверяются компонентными тестами.

**Никаких моков.** Юнит-тесты работают только с реальными объектами
(чистые функции, доменные структуры). Если тест требует внешней
зависимости — это не юнит, это компонентный тест. Никаких mock-функций,
stub-интерфейсов, fake-БД или func-полей в `Deps` ради подмены.
Единственное допустимое исключение — `testClock` для детерминированного
времени (это стандартная идиома языка, а не подмена).

**TDD outside-in поверх slice'а.** Компонентный Gherkin-сценарий для
этого slice'а **уже написан** (опус-этап + скилл компонентных тестов
до начала реализации). До начала Шага 3 он красный — slice ещё
не реализован. По мере того как sonnet реализует модули по списку
выше, сценарий проходит от **красного к зелёному**.

Главный навигационный артефакт здесь — **таблица `## Gherkin-mapping`**
в карточке slice'а (`docs/design/<slug>/slices/<n>-<name>.md`,
сделана opus'ом на Шаге 8.3). В ней для каждого Then-шага каждого
сценария явно указан узел графа (модуль / маппинг адаптера),
который этот Then зеленит. Sonnet использует таблицу как «карту
зелёного»: реализовал узел X — нашёл в таблице все строки с этим X —
прогнал ровно те сценарии, которые эти строки покрывают.

Алгоритм:

1. Реализован конструктор / модуль логики → найти в таблице строки
   с этим узлом → прогнать соответствующие Gherkin-сценарии. Если строк
   нет — узел не виден в Gherkin (нормально для чистых конструкторов;
   их зеленят юниты).
2. Реализован I/O-модуль (Success-ветка) и подключён в головном модуле
   slice'а → запустить happy-path сценарии, в чьих строках этот I/O
   фигурирует. Должны позеленеть.
3. Реализована Failure-ветка I/O-модуля + соответствующий маппинг
   в ингресс-адаптере → запустить сценарий отказа, в чьих строках стоит
   этот класс ошибки. Должен позеленеть.
4. К концу Шага 3 **все строки таблицы `## Gherkin-mapping` закрыты,
   все компонентные сценарии этого slice'а зелёные**.

Это даёт детерминированную обратную связь: «реализовал X → должен
позеленеть сценарий Y» прямо из таблицы, без догадок.

Если компонентный сценарий зелёным не становится, а юнит-тесты модулей
зелёные и таблица `## Gherkin-mapping` говорит, что узел реализован —
**проблема не в коде, а в спецификации**: либо контракт slice'а
не соответствует тому, что обещано в OpenAPI/AsyncAPI, либо
`contracts-graph.md` не учёл какой-то путь, либо строка таблицы
указывает на несуществующий узел. Стоп, сообщить оператору,
opus возвращается на Шаг 8.3 / Шаг 9 проверить согласованность.

Если юнит-тест красный потому, что в самом тесте ошибка — править тест.
Если контракт явно противоречит реальности — стоп, сообщить (см. Шаг 2).

Конструкторы доменных структур — главный объект юнит-тестирования
slice'а. Они инкапсулируют все правила валидации, по формуле получают
«1 happy + по тесту на каждую ветку антецедента». Модули логики —
чистые функции, тестируются по той же формуле (обычно только happy,
если антецедент тривиален). Головной модуль, ингресс-адаптер и
инфраструктурный модуль юнит-тестами не покрываются — только
компонентные тесты через реальный вход.

### Шаг 3.5. Обязательная самопроверка перед тестами

Выполнить четыре grep-команды. Каждая **должна вернуть пустой вывод**.
Непустой вывод = нарушение = исправить до перехода к Шагу 4. Без исключений.

```bash
# 1. Head-модуль не знает про зависимости I/O.
#    Если видишь sql/http/amqp/kafka — перенеси в IO-объект.
grep -rn "database/sql\|net/http\|\"io\"\|amqp\|kafka" internal/slice/*/head.go

# 2. Тесты не вызывают головной модуль.
#    Head — склейка уже протестированных частей. Его корректность
#    доказывает компонентный сценарий, а не юнит. Если видишь вызов
#    Process*/Handle* в *_test.go — удалить весь блок теста.
grep -rn "^func Test" internal/slice/*/*_test.go \
  | grep -iE "Process|Handle|Head|Orchestrat"

# 3. Нет тест-дублей (stubs, fakes, mocks).
#    Нельзя: type alwaysFailX struct{}, type mockDB struct{}.
#    Единственное исключение — testClock (инъекция времени — стандарт Go).
#    Если видишь любой другой тип-заглушку — удалить тест, который его требует.
grep -rn "^type.*struct{}" internal/slice/*/*_test.go \
  | grep -iv "testclock\|testClock"

# 4. Deps не содержит полей под подмену в тестах.
#    Каждое поле Deps — одна реальная зависимость. Если поле нужно только
#    чтобы в тесте подставить заглушку (Rand io.Reader, Persist func(...))
#    — убрать поле, захардкодить реальную зависимость внутри функции.
grep -n "func(" internal/slice/*/register.go
```

**Типичная ловушка:** тесты головного модуля с реальным in-memory SQLite
кажутся «честными» (не мок же!), но нарушают правило — головной модуль
не тестируется юнитами **независимо от того, реальные ли зависимости**.
Компонентный сценарий уже покрывает этот путь через реальный HTTP-вход.

### Шаг 4. Прогнать тесты локально

```bash
go test ./...                                          # юнит-тесты
./component-tests/scripts/run-tests.sh healthy         # компонентные тесты
```

Оба должны быть выполнены **до** отчёта оператору о готовности.
Не останавливаться после `go test` — компонентные тесты обязательны.

**Критерий зелёного состояния перед ревью:**

- Юнит-тесты: все зелёные.
- Компонентные тесты: зелёные сценарии **текущего slice'а** и всех
  ранее реализованных slice'ов. Красные — только сценарии ещё не
  реализованных slice'ов (ожидаемые 404 на не подключённых роутах).
  Красный сценарий реализованного slice'а — стоп, разобраться.

Если **юнит-тест** красный:
- ошибка в реализации модуля → править реализацию, не трогая контракт;
- ошибка в самом тесте (опечатка, неверное ожидание) → править тест;
- контракт модуля противоречит вызывающим модулям → стоп, сообщить
  (см. правило про расхождения в Шаге 2).

Если **компонентный тест** красный на сценарии текущего slice'а:
- модули реализованы, но slice не складывается → проверить головной
  модуль и ингресс-адаптер;
- сценарий ожидает поведение, не описанное в спецификации → стоп,
  сообщить, opus возвращается на Шаг 9 (`contracts-graph.md`).

### Шаг 5. Отметить тикет в backlog.md

```diff
- [ ] компонентные тесты режимов отказа зелёные
+ [x] компонентные тесты режимов отказа зелёные
```

Чек-лист обновляется по каждому подтверждённому пункту, **не одним батчем
в конце**. Backlog — единственный источник истины о статусе.

### Шаг 6. Записать в devlog

`docs/design/<slug>/devlog.md` — журнал реализации, по одному блоку
на тикет, в порядке закрытия. Sonnet добавляет блок текущего тикета
**после** того как локальный CI стал зелёным, и **до** подготовки
коммита (чтобы devlog попал в этот же коммит).

Формат блока:

```
## S<n> — <идентификатор входа> (<YYYY-MM-DD>)

**Что сделано:** одна-две фразы по сути изменения.

**Решения, принятые по ходу:** локальные решения, которые не меняют
архитектуру, но которые читателю через год полезно знать. Если
решений не было — пропустить раздел.

**Что застряло:** если sonnet останавливался на расхождении и opus
правил `contracts-graph.md` — короткое описание ситуации. Если
не застрял — пропустить раздел.

**Тесты:** юниты <число>, покрытие <%>. Компонентные сценарии:
<имена сценариев>, все зелёные.
```

Devlog — для людей, не для CI. Длина блока — 5–15 строк.

### Шаг 7. Подготовить коммит и предложить оператору

Коммит атомарный, в формате `<type>(<scope>): <subject>`:

```
feat(slice-<name>): реализовать <идентификатор входа>

- ингресс-адаптер <name>
- модули <перечисление: конструкторы, логика, I/O>
- юнит-тесты по формуле, покрытие 100%
- компонентный тест happy + <режимы отказа> зелёные
- backlog.md обновлён, devlog.md дополнен

Closes S<n>
```

Где `<идентификатор входа>` — то же, что в заголовке тикета: например,
`HTTP POST /v1/registrations`, `Broker registrations.created`,
`gRPC RegistrationService.Create`, `CLI registrations:cleanup`.

Сообщение коммита показывается оператору **до** `git commit`. Аппрув —
явный («да», «коммить», «ок»). Без аппрува — не коммитим.

### Шаг 8. Самопроверка перед пушем, ревью оператора, Push, PR

**Пуш делается только после явного аппрува оператора в терминале.
Без аппрува — не пушить, даже если всё зелёное.**

#### 8.1. Повторить самопроверку из Шага 3.5

Прогнать все четыре grep-команды ещё раз — после коммита код мог
измениться. Пустой вывод по всем четырём — переходить к 8.2.

#### 8.2. Показать diff оператору

```bash
git diff main...HEAD --stat
git diff main...HEAD
```

Вывести результат в терминал целиком. Не резюмировать, не сокращать —
оператор смотрит на реальный код.

#### 8.3. Ждать явного аппрува

Sonnet пишет:
> «Diff выше. Жду аппрув перед пушем.»

Аппрув — явное слово: «пуш», «да», «ок», «мержи», «push».
Любой другой ответ (вопрос, уточнение, молчание) — не аппрув.
Не пушить, разобраться с вопросом и повторить 8.2–8.3.

#### 8.4. Push и PR

После аппрува:

```bash
git push -u origin feat/slice-<name>
gh pr create --base main --fill
```

Шаблон описания PR:

```
## Тикет
S<n> — slice <name>: <идентификатор входа>

## Что сделано
<пункты Definition of Done, отмеченные галочками>

## Спецификация
docs/design/<slug>/slices/<n>-<name>.md

## Тесты
- юниты: <число>, покрытие <%>
- компонентные: <число> сценариев Gherkin зелёные

## Чек-лист TBD
- [x] ветка от свежего main
- [x] локальный CI зелёный
- [x] backlog.md обновлён
- [x] devlog.md дополнен
```

### Шаг 9. Запросить ревью оператора

После открытия PR sonnet **явно сообщает оператору**: «PR #<номер>
готов к ревью, ссылка: <url>». Сообщение содержит:

- ссылку на PR;
- краткое резюме (1–2 фразы) — что изменилось;
- список ключевых файлов для просмотра (карточка slice'а, головной
  модуль, тесты);
- вопрос: «можно ли мержить?».

Sonnet ждёт **явного аппрува**: «мержи», «ок», «approved». Без аппрува
— не мержить, даже если CI зелёный. До получения аппрува sonnet
не переходит к следующему тикету.

Если оператор просит правки — sonnet возвращается на Шаг 3
(или дальше — туда, где правка релевантна), вносит изменения,
пушит в ту же ветку, обновляет PR, повторяет Шаг 9.

### Шаг 10. Дождаться зелёного CI на PR

Параллельно с ревью оператора PR проходит CI. Пока CI не зелёный —
не мержить, к следующему тикету не переходить. Если CI красный —
ремонт делается коммитами в ту же ветку, не новой веткой и не на main.

К моменту мержа должны быть выполнены **оба условия**: аппрув оператора
(Шаг 9) и зелёный CI (Шаг 10).

### Шаг 11. Мерж и подтягивание main

После аппрува оператора и зелёного CI:

```bash
gh pr merge --squash --delete-branch
git checkout main
git pull --ff-only origin main
```

Дождаться, что CI на main зелёный после мержа. Только после этого
тикет считается **закрытым**.

### Шаг 12. Переход к следующему тикету

- Открыть `backlog.md`.
- Найти следующий тикет с выполненными зависимостями.
- Вернуться на Шаг 1 (создать новую ветку от свежего main).

## Definition of Done скилла

Применительно ко всему пакету:

- Все тикеты в `backlog.md` отмечены `[x]`.
- main зелёный.
- Все компонентные сценарии Gherkin зелёные.
- Devlog `docs/design/<slug>/devlog.md` заполнен по тикетам.

Главное в этом скилле — тактовость. Sonnet не ныряет в реализацию глубоко, он движется по шагам, в каждом шаге есть конкретное действие и проверка. Аппрув оператора в трёх точках: на стадии коммита, на стадии PR-мержа, и (опционально) при обнаружении противоречия в спецификации.

Хендофф: что opus передаёт sonnet

Между двумя скиллами стоит хендофф-чеклист. Это не отдельный документ — это раздел в backlog.md, который opus заполняет на Шаге 12 своего скилла (program-design.skill) и который sonnet проверяет на Шаге 0 своего (program-implementation.skill). Полный список пунктов живёт в самих скиллах (Шаг 12 для opus, Шаг 0 для sonnet) — здесь повторять его не будем, чтобы не плодить копий, которые завтра разойдутся.

Что важно понимать про этот стык:

  • Чеклист — единственный интерфейс между opus и sonnet. Sonnet читает не «общую атмосферу проекта», а конкретные пункты [x]. Если пункт не закрыт, это операционный сигнал «не начинать».
  • Заполняет opus, проверяет оператор. Аппрув оператора нужен явно — sonnet перед стартом смотрит и на галочки, и на отметку об аппруве.
  • Источник истины — скилл, не статья. Если по ходу работы обнаружится, что в чеклисте чего-то не хватает, поправка идёт в program-design.skill Шаг 12. Здесь, в статье — описание схемы, не контент чеклиста.

После аппрува хендоффа — opus в этом проекте больше не нужен до конца реализации. Он вернётся только если sonnet остановится на противоречии (см. правило про расхождения с contracts-graph.md).

Это разделение труда снимает с оператора главную головную боль агентских процессов: в каждый момент понятно, кто отвечает за следующий шаг.

Памятка оператора: где аппрувы, где автономия

Оператор устаёт, когда не понимает, на каком этапе он сейчас и что от него хотят. Поэтому фиксируем границы.

Аппрувы оператора (обязательно):

  1. После пакета opus'а — перед началом реализации (запись в хендофф-чеклист в формате @<github-handle>, <YYYY-MM-DD>).
  2. Перед каждым git commit (sonnet показывает сообщение коммита).
  3. Ревью PR — sonnet явно запрашивает «можно ли мержить?» с ссылкой на PR.
  4. При остановке sonnet'а на противоречии в спецификации.

Автономия sonnet'а (без аппрува):

  1. Создание ветки от main.
  2. Реализация модулей по спецификации.
  3. Юнит-тесты по формуле.
  4. Локальный прогон тестов и линта.
  5. Локальный фикс — пока контракт не нарушен.
  6. Отметка [x] в backlog.md.
  7. Запись в devlog.md.
  8. Push ветки в origin (без мержа).
  9. Создание PR (без мержа, до Шага 9 ревью).

Автономия opus'а (без аппрува):

  1. Чтение FRD, OpenAPI, AGENTS.md.
  2. Формулирование задачи в одну фразу (с предъявлением оператору).
  3. Перечисление срезов и модулей.
  4. Любые внутренние правки документации до момента «пакет готов».

Аппрувы opus'а (обязательно):

  1. Перед фиксацией пакета — оператор смотрит хендофф-чеклист.
  2. При обнаружении неустранимой развилки в проектировании — стоп, оператор решает.

Эта таблица должна жить в AGENTS.md проекта. Тогда любой новый агент, попавший в репозиторий, сразу понимает свою роль.

Что дальше

Оба скилла готовы как файлы. Их можно положить в любой Go/Python/JVM-проект — они языко-независимые, потому что описывают дисциплину, а не синтаксис.

В следующей статье прикручиваем оба скилла к ubik-life/passkey-demo-api. Там уже есть OpenAPI, есть скилл компонентных тестов с зелёным smoke и красными слайс-сценариями. Подключаем program-design.skill к opus, прогоняем по эндпоинтам, получаем пакет проектной документации. Потом подключаем program-implementation.skill к sonnet, реализуем по тикетам в TBD-режиме, доводим main до зелёных компонентных тестов.

После того как пройдём passkey-demo-api по этим скиллам, их можно будет считать проверенными — и переносить в Ubik. Без проверки на боевом сервисе скиллы остаются красивой методичкой — мы это уже один раз проходили со скиллом компонентных тестов: на бумаге всё ясно, в репозитории сразу всплывают зазоры.

Источники

  1. Morev M. Дисциплина проектирования программ. Скилл для opus и бэклог для sonnet. codemonsters.team, 2026.
  2. Morev M. Сколько компонентных тестов нужно сервису. Логический вывод. codemonsters.team, 2026.
  3. Morev M. Компонентные тесты на практике. Скилл для агента и разбор passkey-demo-api. codemonsters.team, 2026.
  4. Morev M. Применение и доработка скилла компонентных тестов. Сессия с opus и хендофф sonnet'у. codemonsters.team, 2026.
  5. Jimmy Bogard. Vertical Slice Architecture. jimmybogard.com, 2018.
  6. Paul Hammant. Trunk Based Development. trunkbaseddevelopment.com.
  7. Linger R. C., Mills H. D., Witt B. I. Structured Programming: Theory and Practice. Addison-Wesley, 1979.
  8. Meyer B. Applying "Design by Contract". IEEE Computer, 1992.
  9. ubik-life/passkey-demo-api — учебный сервис, на котором будут проверяться скиллы.
  10. SKILL.md — открытый стандарт для агентских скиллов (Anthropic).
  11. AGENTS.md — открытый стандарт для агентских инструкций (OpenAI / Linux Foundation).