---
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/`):

1. **Существование и доступность.** Каждый переиспользуемый модуль реально есть
   в коде **и достижим по видимости** из пакета-интегратора. В Go пакетно-приватная
   (со строчной буквы) функция из чужого пакета недоступна — нельзя писать «зовём
   листья соседних слайсов», если листья приватные. Варианта два: лист уже
   экспортный, **или** в дизайн закладывается отдельный chore-тикет «промоут в
   экспортный вход» (`Evaluate`/подобный) — он идёт **до** тикета интегратора и
   имеет свой DoD (экспорт + делегирование головы-источника + юниты по формуле).
   Нельзя оставлять «как-нибудь переиспользуем» — механизм называется явно.

2. **Сигнатура.** Имя и сигнатура каждого переиспользуемого модуля сверены с кодом
   (входные типы, возврат, какие `deps` нужны), а не списаны с карточки-источника.

3. **Не-дублирование добычи входа.** Посчитать, сколько раз при выбранном механизме
   повторяются **валидация входа** (`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. Чек-лист сверки

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

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` или в стандартной
   библиотеке языка. **Для слайса-интегратора** (переиспользует модули
   уже реализованных слайсов): тип/модуль сверен с **реальным кодом**, а не
   с карточкой-источником, и **достижим по видимости** из пакета-интегратора
   (приватный лист соседнего пакета — расхождение; см. Шаг 3, «Жёсткое правило
   для слайса-интегратора»).
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.

**Жёсткое правило: шаблон ниже — каркас, а не финальный текст.** Каждый
обобщённый пункт 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'у приступать.
