| name | program-design |
| description | Проектирование программы по дисциплине рациональной разработки. Применять, когда есть FRD/задача и зафиксированный контракт API (OpenAPI/AsyncAPI), и нужен пакет проектной документации для последующей реализации (вертикальные срезы, контракты модулей, антецеденты/консеквенты, юнит-тесты по формуле, компонентные сценарии). Не применять, если контракт API или карта режимов отказа в README отсутствуют — сначала спроектировать их отдельной задачей. |
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), действие клиента,
действие оператора. Это раздел «Карта режимов отказа», без него
компонентные сценарии отказа описать нельзя.
- Компонентные сценарии Gherkin для эндпоинтов слайсов уже
написаны и закоммичены (по
skills/component-tests/SKILL.md):
один happy path + сценарий на каждый различимый режим отказа,
для каждого эндпоинта будущего slice'а. Это исполняемая
спецификация, против которой ведётся обратная сверка дизайна
на Шаге 8.
AGENTS.md, CLAUDE.md, README — чтобы знать конвенции проекта.
Жёсткое правило. Если контракт (OpenAPI/AsyncAPI) отсутствует,
таблица отказов в README отсутствует, или компонентные сценарии
Gherkin для эндпоинтов будущих slice'ов не написаны — проектирование
не начинается. Opus останавливается, сообщает оператору, и предлагает
сначала зафиксировать недостающие артефакты как отдельную задачу.
Без контракта проектировать слайс не на чем: нет источника истины
о форме запроса, ответа и кодах ошибок. Без таблицы отказов
непонятно, какие компонентные сценарии отказа писать (правило
различимости — см. skills/component-tests/SKILL.md). Без 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
Каждый узел — модуль с одним входом и одним выходом.
На каждом узле — фраза «что делает», в одно предложение.
Раскладка slice'а в код (файлы пакета)
Каждый slice — самодостаточный пакет internal/slice/<name>/ со строгим
набором файлов; узлы дерева ложатся на них один-в-один:
| Файл | Узел дерева |
|---|
head.go | голова Process<Slice>(req, deps) -> Result<…, Error> |
adapter.go | ингресс-адаптер (парсинг) |
logic.go | конструкторы доменных структур и чистые функции |
domain.go | типы/сообщения, специфичные slice'у |
errors.go | sentinel-ошибки slice'а |
register.go | Deps + подключение slice'а к своей точке входа |
Голова именуется Process<Slice> и лежит в head.go — её видно сразу. Не
прятать голову/адаптер за обёрткой-делегатом: они экспортируются напрямую.
Кросс-сквозное (типы отчёта/ответа, автономные I/O-объекты, общий egress) — в
общих пакетах, не в slice'е. Образец раскладки — ubik/passkey-demo-api.
Жёсткое правило одного аргумента (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(...)) для её сборки выше по пайпу. Пересчитать.
Антипример (как НЕ надо).
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. У них нет «полезного выхода», который можно было бы
закодировать в тип; они трубы (см. Шаг 5 и Шаг 8.1).
Как чинить. Завести подтип, несущий инвариант в типе. Заменить guard
на конструктор подтипа.
Антипример (как НЕ надо).
ProcessX(req) -> Response:
| NewCommand(req) -> Command
| loadEntity(cmd.id) -> Entity
| checkEntityFresh(entity, now) -> () <-- guard
| doWork(entity) -> WorkResult
checkEntityFresh — guard: сигнатура -> () (или -> error в Go) без
полезного выхода. На вход — Entity, на выходе — та же Entity живёт
дальше в пайпе как ни в чём не бывало.
Как надо.
ProcessX(req) -> Response:
| NewCommand(req) -> Command
| loadEntity(cmd.id) -> Entity
| NewFreshEntity({entity, now}) -> FreshEntity <-- конструктор подтипа
| doWork(fresh) -> WorkResult
FreshEntity — отдельная доменная структура с неэкспортируемыми полями.
NewFreshEntity(input) -> (FreshEntity, error) проверяет инвариант
(now < entity.ExpiresAt()); невалидные данные → структура не
создаётся, возвращается доменная ошибка (ErrEntityExpired).
Дальнейшие шаги пайпа принимают FreshEntity, не Entity. Система типов
гарантирует, что в doWork нельзя случайно передать просроченную
сущность — код не скомпилируется.
Применимость. Это расширение «валидация — через конструкторы»
(Шаг 4) с примитивов на доменные сущности. Любой инвариант над
уже-валидной доменной структурой, который требует учёта внешнего
факта (текущее время, подпись, статус другой сущности, прохождение
верификации), оформляется как конструктор подтипа, не как guard-функция.
Подтип регистрируется в messages.md рядом с базовым типом.
Эффект на формулу юнит-тестов (Шаг 8.1). Конструктор подтипа
считается по той же формуле 1 happy + Σ ветки антецедента. Никакого
дополнительного покрытия не требуется — наоборот, исчезает строка под
guard-функцию, которая считалась бы отдельно.
Псевдокод пайпа головного модуля
Головной модуль slice'а должен читаться как «конспект работы slice'а» —
видна вся последовательность шагов за один взгляд.
Форма головного модуля slice'а — линейный пайп исполнения. Пять-десять
шагов, каждый шаг — отдельный модуль из дерева, поток данных идёт через
Result<T, Error> (или языковой эквивалент: (T, error) в Go,
Mono<Result<T>> в Kotlin/Reactor, ?-оператор в Rust). Никаких
вложенных условий и циклов в самом пайпе.
В карточке 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-статус.
Для CLI/пакетного инструмента «формат ответа» — это машинный отчёт + код
возврата. Их формирует общий egress (одна точка на все slice'ы: маппинг
ErrXxx → error.code, запись отчёта, вычисление кода возврата), а не голова
каждого slice'а. Голова возвращает доменный результат/ошибку; egress — это
эквивалент ингресс-адаптера HTTP, но единый, потому что отчёт у инструмента
однороден (одна схема, один набор error.code).
Следствие для тестов. Юнит-тест головного модуля — интеграционный
тест (пайп собирает реальные зависимости). Его не проектируют и
не пишут. Корректность пайпа доказывает компонентный сценарий через
реальный вход slice'а. Ветки ошибок I/O покрываются сценариями отказа
(db_locked, db_disk_full и т.д.) — не юнит-тестами.
Следствие для Deps. В Deps головного модуля нет полей, которые
нужны только ради подмены в тесте (Rand io.Reader, Persist func(...),
Now func() time.Time — если только это не clock.Clock-инъекция
для детерминированного времени). Если поле в Deps нужно только чтобы
подставить заглушку в тест — это сигнал попытки юнит-тестировать head.
Такое поле не вводить. Реальную зависимость захардкодить внутри функции.
Жёсткое правило для слайса-интегратора: сверка переиспользования с кодом
Слайс-интегратор — слайс, у которого в таблице срезов колонка «Новые
интеграции» = —, а работу он делает, переиспользуя модули уже реализованных
слайсов (типичный пример — финальный assess/pipeline, собирающий результат
из ранее построенных слоёв). Для такого слайса источник истины — реальный код
переиспользуемых слайсов, а не их проектные карточки. Карточки дрейфуют от кода:
слой мог быть отложен в TBD, функция переименована, лист так и остался
пакетно-приватным. Проектировать интегратор по карточкам = заложить расхождение.
Перед фиксацией дерева модулей (Шаг 3) интегратор проходит три механические
сверки с кодом (чтение/grep по репозиторию, не по docs/design/):
-
Существование и доступность. Каждый переиспользуемый модуль реально есть
в коде и достижим по видимости из пакета-интегратора. В Go пакетно-приватная
(со строчной буквы) функция из чужого пакета недоступна — нельзя писать «зовём
листья соседних слайсов», если листья приватные. Варианта два: лист уже
экспортный, или в дизайн закладывается отдельный chore-тикет «промоут в
экспортный вход» (Evaluate/подобный) — он идёт до тикета интегратора и
имеет свой DoD (экспорт + делегирование головы-источника + юниты по формуле).
Нельзя оставлять «как-нибудь переиспользуем» — механизм называется явно.
-
Сигнатура. Имя и сигнатура каждого переиспользуемого модуля сверены с кодом
(входные типы, возврат, какие deps нужны), а не списаны с карточки-источника.
-
Не-дублирование добычи входа. Посчитать, сколько раз при выбранном механизме
повторяются валидация входа (NewAuditTarget/NewConfig/доменные
конструкторы) и чтение I/O. Если интегратор зовёт N готовых голов — это N×
валидация + N× чтение на каждом запуске (готовые головы самодостаточны и
добывают вход внутри себя). Часто дешевле добыть вход один раз и звать
чистые листья поверх предчитанных данных. Выбор механизма переиспользования
(экспортный лист над общими данными / вызов голов / общий пакет) и его цена
фиксируются в карточке слайса и утверждаются оператором как развилка (DON'T:
принимать архитектурное решение без аппрува — см. «Зона ответственности»).
Отложенные/отсутствующие зависимости. Слой или интеграция, помеченные TBD /
отложенными в таблице срезов или статусах (например L2/style, если LinterRunner
ещё не введён), в пайплайн интегратора не попадают. Сверить статусы слайсов
по коду и backlog: интегратор собирает только то, что реально существует.
Цена пропуска. Без этой сверки дизайн выглядит правильным по карточкам, но
ссылается на несуществующий слой и на недостижимые приватные функции — расхождение
вскрывается уже в реализации (не компилируется / нечего звать). Это произошло на
дизайне S7 assess (см. карточку slices/07-assess.md, секция «Решение по reuse»):
карточка из проектного пакета тянула в пайплайн отложенный L2 и декларировала «зовём
листья» при пакетно-приватных листьях. Правило добавлено, чтобы не повторялось.
Шаг 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 — обычно не нужен (см. правило про Rand в разделе
«Головной модуль — оркестратор-труба»).
Алгоритм проверки. После заполнения каждого контракта (Шаг 5) — пройти
по строке Dependencies: каждого модуля и сверить со столбцом «Запрещено».
Хоть одно совпадение — стоп: возврат к Шагу 3, ввести I/O-объект, сделать
этот модуль его методом. Это механический чек, а не творческое решение —
он либо проходит, либо нет.
Цена пропуска проверки — на следующих слайсах оператор находит сырой
*sql.DB в дизайне и просит переделать. Это уже произошло один раз
на S3 (см. feedback_io_autonomous_store); чек-лист добавлен именно
ради того, чтобы не повторилось.
Уточнения:
- 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),
не его внутренние зависимости.
Имя объекта по типу интеграции:
| Интеграция | Имя объекта | Зависимость (скрыта внутри объекта) |
|---|
| База данных | Store | *sql.DB |
| Внешний HTTP API | Client | *http.Client + baseURL |
| Брокер сообщений | Publisher / Consumer | соединение брокера |
В контракте (Шаг 5) строки I/O-модуля:
Input (data): — одно доменное сообщение;
Dependencies: — — (зависимость инкапсулирована в объект,
головной модуль её не видит);
- в описании
Deps головного модуля — поле типа Store / Client /
Publisher, не сырая зависимость (*sql.DB, *http.Client…).
Признак нарушения при проверке дизайна: сырая зависимость (*sql.DB,
*http.Client) в строке Dependencies: контракта или в Deps headmodule'а.
Это значит — 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'а
(правило различимости — см.
skills/component-tests/SKILL.md).
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. Чек-лист сверки
Для каждой строки таблицы проверить:
- Узел существует. Указанный модуль/маппинг описан в Шаге 5 (контракты)
и появится в графе на Шаге 9.
- Ветка соответствует. Если Then ожидает ошибку — узел должен иметь
соответствующий Failure-путь с тем же классом ошибки. Если Then ожидает
эффект на интеграции — узел должен быть I/O-модулем с тем эффектом.
- Формат ответа адаптера согласован. Если Then проверяет HTTP-код,
заголовок (
Retry-After), error.code в теле — в карточке slice'а
зафиксирован маппинг класса ошибки в этот формат, либо ингресс-адаптер
делегирует это общему хелперу из infrastructure.md.
- Все 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. Чек-лист сверки
Для каждой стрелки графа проверить шесть пунктов:
- Тип на стрелке существует в
messages.md или в стандартной
библиотеке языка. Для слайса-интегратора (переиспользует модули
уже реализованных слайсов): тип/модуль сверен с реальным кодом, а не
с карточкой-источником, и достижим по видимости из пакета-интегратора
(приватный лист соседнего пакета — расхождение; см. Шаг 3, «Жёсткое правило
для слайса-интегратора»).
- Имя сигнатуры на стрелке совпадает с тем, что записано в карточке
модуля-получателя. Не «createRegistration», в одном месте «registerUser»
в другом.
- Консеквент отправителя ⊆ антецеденту получателя. То, что модуль A
гарантирует на выходе, должно полностью удовлетворять тому, что модуль B
требует на входе. Если A гарантирует «email непустой», а B требует
«email непустой и подтверждённый» — расхождение, B будет падать.
- Тип ошибки согласован. Если A может вернуть
ErrEmailInvalid,
а B этот класс ошибок не разбирает — расхождение, ошибка протечёт
мимо обработчика.
- Покрытие Gherkin-сценариев. Каждый Then-шаг каждого Gherkin-
сценария slice'а ложится на конкретный узел графа или маппинг
в ингресс-адаптере (таблица из Шага 8.4). Если Then не находит
узла — расхождение между исполняемой спецификацией и дизайном.
Если узел графа не упомянут ни одним Then — узел кандидат на
удаление (мёртвая логика), либо в Gherkin не хватает сценария.
В обоих случаях — возврат к Шагу 3 или Шагу 5, не «починим в
реализации».
- Один 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.
Жёсткое правило: шаблон ниже — каркас, а не финальный текст. Каждый
обобщённый пункт DoD должен быть заменён конкретикой из уже выполненных шагов.
Плейсхолдеры в готовом тикете — признак, что Шаг 11 не завершён.
Таблица подстановок:
| Пункт DoD (шаблон) | Откуда брать конкретику |
|---|
| «ингресс-адаптер реализован» | Указать имя функции и файл из infrastructure.md (Шаг 7) |
| «конструкторы … реализованы» | Перечислить конкретные NewT из карточки слайса (Шаг 3/5) |
| «модули логики реализованы» | Перечислить конкретные функции из карточки слайса (Шаг 3) |
| «модуль I/O изолирует…» | Указать имя I/O-объекта и его методы (Шаг 6) |
| «головной модуль реализован» | Указать имя Process<Slice> и файл head.go (Шаг 3) |
| «slice подключён» | Указать конкретный файл и точку входа из infrastructure.md (Шаг 7) |
| «юнит-тесты по формуле» | Вставить итоговое число из таблицы Шага 8.1 с разбивкой по модулям |
| «компонентный тест зелёный» | Назвать конкретные сценарии из .feature (Шаг 8.3/8.4) и команду запуска |
Шаблон тикета:
### TICKET S<n> — slice <name>: <идентификатор входа>
**Спецификация:**
- `docs/design/<slug>/slices/<n>-<name>.md` (главный документ)
- `docs/design/<slug>/messages.md` — <перечислить типы, специфичные слайсу>
- `docs/design/<slug>/contracts-graph.md` — секция «S<n> <name>»
- `docs/design/<slug>/infrastructure.md` — <что именно: подключение, миграции, Deps>
**Зависимости:** <S<m>, S<k> — что именно импортируется (типы, I/O-объекты)>.
Новых внешних Go-зависимостей нет. (или: новые go.mod записи: <список>)
**Ветка:** `feat/slice-<name>`
**Definition of Done:**
- [ ] `<файл>/domain.go`: <конкретные типы и конструкторы из Шага 3>
- [ ] `<файл>/logic.go`: <конкретные функции из Шага 3> — чистые функции, без I/O
- [ ] `<файл>/adapter.go`: `ParseArgs(args,stderr) -> (Request, error)` — <что парсит>
- [ ] `<файл>/head.go`: `Process<Slice>(req, Deps) -> (Report, error)` — линейная труба
- [ ] `<файл>/register.go`: `Deps{<поля>}` + `NewDeps(<аргументы>) -> Deps`
- [ ] `<точка входа>`: <имя функции> добавлен, `"<name>"` убран из заглушек
- [ ] юнит-тесты по формуле написаны и зелёные — `go test ./...` проходит.
**<N> новых тестов**: <Модуль1>(<n1>) + <Модуль2>(<n2>) + … (из таблицы Шага 8.1).
<Голова, адаптер, I/O-объекты> юнитами не покрываются.
- [ ] компонентные тесты зелёные — `<команда запуска>`.
`@wip` снят с `<name>.feature`; сценарии: «<название1>», «<название2>», … (из Шага 8.3).
Ранее зелёные сценарии S1–S<m> продолжают проходить.
- [ ] `backlog.md` обновлён по каждому подтверждённому пункту
- [ ] `docs/design/<slug>/devlog.md` дополнен блоком S<n>
- [ ] PR создан, описание заполнено по шаблону Шага 8 скилла
- [ ] PR смержен в main, CI на main зелёный
**Ссылки на источники:**
- Скилл реализации: `skills/program-implementation/SKILL.md`
- Граф вызовов: `docs/design/<slug>/contracts-graph.md` — секция «S<n>»
- Gherkin-mapping: раздел `## Gherkin-mapping` в `slices/<n>-<name>.md`
- <применённые принципы — «голова без ветвления», «подтип, не guard» и т.п.>
Пример готового тикета
Эталон — тикет S6 drift из rra-docs-another (CLI-тул, L6a без I/O):
### TICKET S6 — slice drift: CLI `drift <path>`
**Спецификация:**
- `docs/design/assess/slices/06-drift.md` (главный документ)
- `docs/design/assess/messages.md` — `Claim`, `DriftFinding`, `DriftCheck`
- `docs/design/assess/contracts-graph.md` — секция «S6 drift»
- `docs/design/assess/infrastructure.md` — подключение в `internal/cli/cli.go`
**Зависимости:** S1 (в main) — `RepoStore`, `ReportSink`, `NewAuditTarget`,
`NewConfig`, `buildReport`, egress. Новых внешних Go-зависимостей нет.
**Ветка:** `feat/slice-drift`
**Definition of Done:**
- [ ] `internal/slice/drift/domain.go`: типы `Claim{Kind,Text,File,Line}`,
`DriftFinding{Claim,Reason}`, `DriftCheck`; конструктор
`NewDriftCheck(structure,claims) -> DriftCheck`
- [ ] `internal/slice/drift/logic.go`: `extractClaims`, `verifyClaims`,
`buildClaimPromptSet`, `mergeSemanticFindings`, `NewDriftReport`,
`buildDriftOutcome` — чистые функции, без I/O
- [ ] `internal/io/judge.go`: интерфейс `Judge` + `NoopJudge{}` (null-object)
- [ ] `internal/slice/drift/adapter.go`: `ParseArgs(args,stderr) -> (Request, error)`
- [ ] `internal/slice/drift/head.go`: `ProcessDrift(req, Deps) -> (Report, error)`
- [ ] `internal/slice/drift/register.go`: `Deps{Store, Judge}` + `NewDeps`
- [ ] `internal/cli/cli.go`: `runDriftCmd` добавлен, `"drift"` убран из `subcommandsTodo`
- [ ] юнит-тесты по формуле написаны и зелёные — `go test ./...` проходит.
**15 новых тестов**: `extractClaims`(2) + `NewDriftCheck`(1) + `verifyClaims`(3)
+ `buildClaimPromptSet`(3) + `mergeSemanticFindings`(3) + `NewDriftReport`(1)
+ `buildDriftOutcome`(2). Голова, адаптер, `NoopJudge` юнитами не покрываются.
- [ ] компонентные тесты зелёные — `./component-tests/scripts/run-tests.sh healthy`.
`@wip` снят с `drift.feature`; сценарии: «опрятный репо → pass»,
«битая ссылка → fail», «путь не существует → path_not_found».
Ранее зелёные сценарии S1–S5 продолжают проходить.
- [ ] `backlog.md` обновлён по каждому подтверждённому пункту
- [ ] `docs/design/assess/devlog.md` дополнен блоком S6
- [ ] PR создан, описание заполнено по шаблону Шага 8 скилла
- [ ] PR смержен в main, CI на main зелёный
**Ссылки на источники:**
- Скилл реализации: `skills/program-implementation/SKILL.md`
- Граф вызовов: `docs/design/assess/contracts-graph.md` — секция «S6 drift»
- Gherkin-mapping: раздел `## Gherkin-mapping` в `slices/06-drift.md`
- Принцип голова без ветвления: `slices/06-drift.md` §«Принцип: голова без ветвления»
Шаг 12. Заполнить хендофф-чеклист
Последний шаг перед открытием дизайн-PR. Чеклист кладётся в начало
docs/design/<slug>/backlog.md (раздел ## Хендофф). Opus заполняет
все галочки [x] сам — включая последнюю строку аппрува, —
проверяя, что соответствующий артефакт реально существует и содержит
то, что требуется.
Формат отметки об аппруве оператора. Opus заполняет последнюю
строку с handle оператора и датой создания дизайн-PR в строгом
формате:
- [x] Оператор аппрувит пакет — @<github-handle>, <YYYY-MM-DD>
Например (дизайн-PR создан 2026-05-10): - [x] Оператор аппрувит пакет — @maxmorev, 2026-05-10.
Семантика: мерж дизайн-PR в main = аппрув пакета оператором. Оператор
выражает согласие с дизайном актом мержа PR: если согласен — мержит,
и предзаполненная строка [x] остаётся в main; если не согласен —
оставляет PR открытым с замечаниями, opus пересобирает пакет и при
необходимости обновляет дату строки на дату следующего пуша. Отдельной
церемонии «оператор флипает галочку после мержа» нет.
Эта строка — единственный детерминированный признак, по которому
sonnet распознаёт «пакет принят» в main (см. skills/program-implementation/SKILL.md
Шаг 0). Если в main строка [ ] или её нет — sonnet к работе
не приступает.
Если оператор требует существенных изменений и ревью затягивается —
opus может временно вернуть строку в [ ] пока пакет переделывается,
чтобы не вводить в заблуждение случайного читателя. На момент создания
финального пуша перед мержем — снова [x].
## Хендофф-чеклист (заполняет opus полностью; merge PR = аппрув оператора)
- [x] OpenAPI / AsyncAPI зафиксирован, все эндпоинты slice'ов в нём описаны
- [x] OpenAPI / AsyncAPI содержит 5xx-ответы с `error.code` для каждого режима отказа
- [x] README содержит таблицу «Карта режимов отказа» (HTTP-статус / тип события / заголовки, действие клиента, действие оператора)
- [x] **Компонентные сценарии Gherkin для эндпоинтов всех slice'ов написаны, закоммичены, стабильны (один happy + сценарий на каждый различимый режим отказа)**
- [x] Папка docs/design/<slug>/ создана и полна
- [x] intent.md — задача в одну фразу
- [x] slices.md — таблица срезов с типом входа, идентификатором, назначением
- [x] messages.md — все структуры данных и Result<T, Error>
- [x] Для каждого slice'а есть отдельный файл с деревом модулей
- [x] У каждого slice'а описан головной модуль (оркестратор пайпа)
- [x] У головного модуля каждого slice'а зафиксирован псевдокод пайпа исполнения (5–10 шагов)
- [x] **Раскладка каждого slice'а по конвенции: head.go (голова `Process<Slice>`), adapter.go / logic.go / domain.go / errors.go / register.go; голова экспортируется напрямую, не за обёрткой (Шаг 3)**
- [x] У каждого модуля логики описаны антецедент и консеквент
- [x] У каждого I/O-модуля slice'а описан контракт и режимы отказа
- [x] **У каждого модуля Input — одна доменная структура / DTO / void; deps вынесены отдельной строкой `Dependencies:` (Шаг 5). Узлов с 2+ data-аргументами в графе нет**
- [x] **I/O-зависимости (БД, HTTP, брокер, файловая система) инкапсулированы в автономный объект `Store`/`Client`/`Publisher`/`Consumer`/`FileStore` (Шаг 6). Сырых `*sql.DB`, `*http.Client`, broker-conn в `Dependencies:` контрактов модулей и в `Deps` головного модуля нет — они скрыты внутри I/O-объекта (Шаг 5, чек-лист `Dependencies:`)**
- [x] **Карточка каждого slice'а содержит таблицу `## Gherkin-mapping`: каждый Then-шаг каждого сценария slice'а привязан к узлу графа или маппингу адаптера (Шаг 8.4)**
- [x] **contracts-graph.md существует, граф каждого slice'а согласован (все стрелки помечены `[x]`, в т.ч. пункт 5 о покрытии Gherkin-сценариев)**
- [x] **Для слайса-интегратора (колонка «Новые интеграции» = `—`, переиспользует модули других слайсов): переиспользуемые модули сверены с реальным кодом — существуют, достижимы по видимости (приватные листья → заложен chore-тикет на экспорт ДО интегратора), сигнатуры совпадают; механизм переиспользования и его цена (N× валидация/чтение vs 1×) зафиксированы в карточке и утверждены оператором; отложенные/TBD-слои в пайплайн не включены (Шаг 3)**
- [x] Для конструкторов доменных структур и чистых функций логики посчитаны юнит-тесты по формуле
- [x] **В таблице юнит-тестов каждой карточки слайса нет головного модуля, нет I/O-модулей и нет ингресс-адаптера: все три — трубы, проверяются только компонентными сценариями (Шаг 8.1)**
- [x] infrastructure.md — описан инфраструктурный модуль приложения
- [x] backlog.md — тикеты по одному на slice, с зависимостями
- [x] Оператор аппрувит пакет — @<github-handle>, <YYYY-MM-DD>
Все строки в шаблоне выше показаны как [x] — это норма для готового
к мержу дизайн-PR. Если какая-то позиция на момент пуша остаётся
недозакрытой (явный сабоптимальный выбор, расхождение со скиллом и
т.п.) — оставить [ ] и описать в карточке слайса в секции «Решения
по дизайну», чтобы оператор увидел при ревью.
Definition of Done скилла
- Все 12 шагов пройдены.
- Папка
docs/design/<slug>/ создана и заполнена.
backlog.md содержит тикеты по одному на slice.
- Хендофф-чеклист в
backlog.md полностью заполнен [x] (включая последнюю строку с handle и датой создания PR).
- Дизайн-PR открыт; ожидается ревью оператора. Мерж PR = аппрув = разрешение sonnet'у приступать.