Два скилла дисциплины. Скилл проектирования для 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. Здесь — только сами скиллы и обоснование, почему они выглядят именно так.
В этой главе рассмотрим:
- Зачем разделять скилл на два
- Vertical slice — несущая конструкция
- Граница адаптера и логики. Конструкторы вместо фабрик
- Головной модуль slice'а — пайп исполнения
- Trunk Based Development — операционный режим
- Скилл проектирования для opus
- Скилл реализации для sonnet
- Хендофф: что opus передаёт sonnet
- Памятка оператора: где аппрувы, где автономия
- Что дальше
Зачем разделять скилл на два
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 }
Что это даёт:
- Невалидный
Handleневозможно создать. Полеvalueне экспортировано, литералdomain.Handle{value: "..."}снаружи пакета не скомпилируется. Ноль-значениеdomain.Handle{}существует, но оно явно отличимо и используется только для ранних возвратов из конструктора. - Валидация — в одном месте. Все правила handle живут в
NewHandle. Любой код, получившийHandle, может ему доверять без проверки. - Сигнатура честная.
NewHandle(raw string) (Handle, error)— видно сразу, что метод может провалиться. Вызывающий обязан разобрать ошибку, иначе компилятор заругается на неиспользуемое значение. - Чистая функция. Конструктор не лезет в БД, не пишет в логи, не зависит от глобального состояния. Юнит-тестируется тривиально по формуле: 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'а:
- Линейный пайп. Видна последовательность шагов, без вложенных
условий и циклов. Если в головном модуле slice'а появляется
forилиif x && y, у которого нет очевидной семантики «тут две реальные ветки выполнения, и обе описаны как отдельные slice'ы» — это сигнал, что часть логики просочилась в оркестратор. Унести её в отдельный модуль. - Каждый шаг — отдельный модуль. Не приватный метод-помощник на пять строк, не лямбда — именно отдельный, самостоятельно тестируемый модуль с антецедентом и консеквентом.
- Поток через
Result/(T, error). Шаги склеиваются через языковую идиому проброса успеха и ошибки:.pipe/flatMapв Kotlin/Scala/Rust, раннийreturn nil, errв Go,?-оператор в Rust. Цель одна: ошибка не теряется, успех протекает дальше. - Никакой бизнес-логики в самом пайпе. Пайп оркестрирует, но не считает. Всё, что считает, — внутри модулей-шагов.
- Длина — пять-десять шагов. Если получается пятнадцать — slice спроектирован слишком крупно, дробить.
- Первый шаг — почти всегда конструктор доменной команды.
Превращение
Request(илиMessage, илиargs) в валидированную доменную структуру черезNewT(...)/T.emerge(...). Дальше по пайпу идёт уже доменный объект, не «сырой» вход. - Последний шаг — формирование ответа адаптеру (если 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 простые и жёсткие:
- Один main, никаких длинных feature-веток. Ветка живёт часы, в крайнем случае — день.
- Каждый PR маленький. Один тикет — один PR. Не «реализовал slice плюс по дороге отрефакторил три соседних» — это два или четыре PR.
- Main всегда зелёный. CI прогоняет всё: линт, юнит-тесты, компонентные тесты. Красный main — стоп для всех.
- Между тикетами синхронизация с main. Sonnet перед началом нового тикета подтягивает свежий main, создаёт новую ветку от свежего main.
- Слияние — 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).
Это разделение труда снимает с оператора главную головную боль агентских процессов: в каждый момент понятно, кто отвечает за следующий шаг.
Памятка оператора: где аппрувы, где автономия
Оператор устаёт, когда не понимает, на каком этапе он сейчас и что от него хотят. Поэтому фиксируем границы.
Аппрувы оператора (обязательно):
- После пакета opus'а — перед началом реализации (запись в хендофф-чеклист в формате
@<github-handle>, <YYYY-MM-DD>). - Перед каждым
git commit(sonnet показывает сообщение коммита). - Ревью PR — sonnet явно запрашивает «можно ли мержить?» с ссылкой на PR.
- При остановке sonnet'а на противоречии в спецификации.
Автономия sonnet'а (без аппрува):
- Создание ветки от main.
- Реализация модулей по спецификации.
- Юнит-тесты по формуле.
- Локальный прогон тестов и линта.
- Локальный фикс — пока контракт не нарушен.
- Отметка
[x]вbacklog.md. - Запись в
devlog.md. - Push ветки в origin (без мержа).
- Создание PR (без мержа, до Шага 9 ревью).
Автономия opus'а (без аппрува):
- Чтение FRD, OpenAPI, AGENTS.md.
- Формулирование задачи в одну фразу (с предъявлением оператору).
- Перечисление срезов и модулей.
- Любые внутренние правки документации до момента «пакет готов».
Аппрувы opus'а (обязательно):
- Перед фиксацией пакета — оператор смотрит хендофф-чеклист.
- При обнаружении неустранимой развилки в проектировании — стоп, оператор решает.
Эта таблица должна жить в 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. Без проверки на боевом сервисе скиллы остаются красивой методичкой — мы это уже один раз проходили со скиллом компонентных тестов: на бумаге всё ясно, в репозитории сразу всплывают зазоры.
Источники
- Morev M. Дисциплина проектирования программ. Скилл для opus и бэклог для sonnet. codemonsters.team, 2026.
- Morev M. Сколько компонентных тестов нужно сервису. Логический вывод. codemonsters.team, 2026.
- Morev M. Компонентные тесты на практике. Скилл для агента и разбор passkey-demo-api. codemonsters.team, 2026.
- Morev M. Применение и доработка скилла компонентных тестов. Сессия с opus и хендофф sonnet'у. codemonsters.team, 2026.
- Jimmy Bogard. Vertical Slice Architecture. jimmybogard.com, 2018.
- Paul Hammant. Trunk Based Development. trunkbaseddevelopment.com.
- Linger R. C., Mills H. D., Witt B. I. Structured Programming: Theory and Practice. Addison-Wesley, 1979.
- Meyer B. Applying "Design by Contract". IEEE Computer, 1992.
ubik-life/passkey-demo-api— учебный сервис, на котором будут проверяться скиллы.- SKILL.md — открытый стандарт для агентских скиллов (Anthropic).
- AGENTS.md — открытый стандарт для агентских инструкций (OpenAI / Linux Foundation).