# ADR-001: Единый frontmatter-контракт (reader+writer+валидатор) и унификация чтения вердиктов

Work Item: **ORCH-076** (ORCH-52c, слой 2 эпика ORCH-52) · Repo: **orchestrator** · Стадия: architecture
Дата: 2026-06-09 · Статус: **Accepted**

> Сквозная версия — [`docs/architecture/adr/adr-0020-frontmatter-contract.md`](../../../architecture/adr/adr-0020-frontmatter-contract.md).

---

## Статус
Accepted

## Контекст

(Подробно — `01-brd.md` §1, `02-trz.md`.) Слой 1 эпика (ORCH-075/52b) дал **описательный**
стандарт `docs/_standards/PIPELINE_DOCS.md`. ORCH-52c — **машинный** слой. Установлено в коде
на ветке задачи:

- `src/frontmatter.py` = **только reader** (`read_frontmatter_value(path, key) -> str | None`,
  never-raise → `None`). В docstring прямой коммент: *«merging into a single parser is a
  follow-up task»* — это и есть данная задача.
- **Парсинг YAML-frontmatter дублируется** в 5+ местах (~10 строк
  `content.startswith("---")` → `split("---", 2)` → `yaml.safe_load` → `isinstance(dict)`):
  `qg/checks.py::check_reviewer_verdict`, `_parse_tests_verdict`, `_parse_deploy_status`,
  `_parse_staging_status`; `security_gate.py::parse_security_status`; плюс `_strip_frontmatter`
  в `review_parse.py` и `security_gate.extract_security_findings`. Каждый — своя обработка
  ошибок и свои reason-строки → риск рассинхрона.
- **Нет машинно-проверяемой схемы** обязательного frontmatter и **нет формальной спеки
  handoff** «что каждая стадия обязана оставить на выходе».

**⚠️ Self-hosting (главное ограничение проектирования).** Затрагиваемый код читает вердикты
**на гейтах** в инструменте, который прямо сейчас обслуживает прод (enduro-trails) из общего
инстанса с общей БД/очередью. Любой регресс чтения вердикта = остановка конвейера ВСЕХ
проектов. Рефакторинг обязан быть **строго обратно совместимым, never-raise, нулевая
регрессия**. Плюс на задаче выставлен лейбл `autoDeploy` (ORCH-089) — это **первый боевой
автодеплой** орка (детали риска — `10-tech-risks.md`).

## Движущие силы (требования)

BR-1…BR-5, NFR-1…NFR-5 (`01-brd.md`), FR-1…FR-6 (`02-trz.md`), AC-1…AC-7
(`03-acceptance-criteria.md`). Ключевые инварианты-ограничители:

- **INV-1** `STAGE_TRANSITIONS` и **состав** `QG_CHECKS` — не меняются (AC-6).
- **INV-2** Семантика каждого вердикта (значение → переход/откат) — 1:1, включая 3-полевой
  контракт tester'а (ORCH-047) и приоритет негативного токена (AC-6, FR-4).
- **INV-3** Контракт `read_frontmatter_value` — неизменен (внешние вызыватели: `usage.py`,
  `notifications.build_status_comment`) (FR-3).
- **INV-4** Валидатор схемы **не hard-fail по умолчанию** — иначе ORCH-52c заблокировала бы
  собственный деплой (её доки и доки соседей ещё без полной схемы) (NFR-3).
- **INV-5** Никаких изменений API и схемы БД (TRZ §4–§5).

---

## Решение

### D1. `src/frontmatter.py` становится единым frontmatter-контрактом (1 модуль, функции)

Выбран **набор функций в существующем leaf-модуле** (не класс, не новый пакет): модуль уже
есть, не зависит ни от чего проектного (только `logging` + ленивый `yaml`), импортируем без
циклов из `qg/checks.py`, `security_gate.py`, `post_deploy.py`, `review_parse.py`. Класс/состояние
не нужны — операции чистые. Это минимизирует blast radius (требование self-hosting).

**Публичный API (имена канонические; точные дефолты — в реализации, контракт фиксирован здесь):**

```python
# --- константы схемы ---
REQUIRED_FIELDS = ("work_item", "stage", "author_agent", "status", "created_at", "model_used")

# --- reader: СОХРАНЁН без изменения контракта (INV-3) ---
def read_frontmatter_value(path: str, key: str) -> str | None: ...

# --- единый парс-примитив (единственная точка YAML-логики) ---
@dataclass(frozen=True)
class FrontmatterParse:
    data: dict          # {} если нет/битый/не-mapping
    has_block: bool     # присутствовал ведущий ---…--- блок
    malformed: bool     # был "---", но < 3 сегментов (незакрытый блок)
    yaml_error: str | None  # текст ошибки yaml.safe_load, иначе None

def parse_frontmatter(content: str) -> FrontmatterParse: ...   # never-raise
def parse_frontmatter_dict(content: str) -> dict: ...          # ярлык → .data; never-raise → {}
def read_frontmatter(path: str) -> dict: ...                   # файл → parse; never-raise → {}

# --- writer ---
def render_frontmatter(data: Mapping[str, object], body: str = "") -> str: ...
    # → "---\n<yaml>\n---\n<body>"; формат совместим со split("---",2)+safe_load; never-raise → body
def write_frontmatter(path: str, data: Mapping, body: str = "") -> bool: ...
    # персист render_frontmatter; never-raise → False (ошибка логируется)

# --- валидатор схемы ---
@dataclass(frozen=True)
class SchemaValidation:
    valid: bool
    missing: list[str]      # отсутствующие/пустые обязательные поля
def validate_schema(data: Mapping, *, required=REQUIRED_FIELDS) -> SchemaValidation: ...  # never-raise

# --- общий хелпер тела (заменяет дубли _strip_frontmatter) ---
def strip_frontmatter(content: str) -> str: ...   # never-raise → content
```

**Контракт всего модуля — never-raise** (NFR-2), как у действующего reader: любая ошибка
(I/O, YAML, сериализация) → `logger.debug/warning` + безопасное значение (`{}` / `False` /
исходный текст), исключение наружу **не выходит**.

`parse_frontmatter` возвращает **структуру** (а не голый dict), чтобы каждый гейт мог
**воспроизвести свои текущие reason-строки 1:1** (см. D2) — это и есть способ сохранить
семантику без переписывания сообщений (INV-2).

### D2. Унифицируется МЕХАНИЗМ парсинга, а НЕ семантика вердиктов

AC-3/FR-4 требуют «читать через единый frontmatter-API, а не дублированной ad-hoc логикой».
Унифицируется **ровно повторяющийся блок** `startswith/split/safe_load/isinstance` →
замена на `parse_frontmatter(content)`. **Token-логика, upper-casing, набор полей, приоритет
негативного токена, fallback `worktree → origin/main` — остаются в каждом гейте без изменений.**
Это сознательное ограничение объёма унификации: общий «умный» verdict-резолвер увеличил бы
риск тонкого регресса на гейтах (недопустимо при self-hosting). Каждый `check_*`/`_parse_*`
сохраняет сигнатуру и `tuple[bool, str]`.

Маппинг состояний `FrontmatterParse` → существующие reason-строки (пример для tester'а,
остальные аналогично):

| Состояние | Прежняя ветка | Сохраняемая reason-строка |
|-----------|---------------|---------------------------|
| `not has_block` | `not content.startswith("---")` | "No YAML frontmatter in test report …" |
| `malformed` | `len(parts) < 3` | "Malformed YAML frontmatter in test report" |
| `yaml_error` | `except yaml.YAMLError` | "Invalid YAML frontmatter in test report: {e}" |
| `data` (dict) | `fm.get(...)` | прежняя token-логика поверх `parse.data` |

Точки перевода (FR-4):

| Парсер | Файл | Поле(я) | Семантика — НЕ менять |
|--------|------|---------|----------------------|
| `check_reviewer_verdict` | `12-review.md` | `verdict:` | APPROVED→дальше; REQUEST_CHANGES→откат |
| `_parse_tests_verdict` | `13-test-report.md` | `result:`/`verdict:`/`status:` (3 равноранг., ORCH-047) | PASS→дальше; FAIL/BLOCKED→откат; негативный токен авторитетен |
| `_parse_deploy_status` | `14-deploy-log.md` | `deploy_status:` | SUCCESS→done; FAILED→откат (БАГ-8) |
| `_parse_staging_status` | `15-staging-log.md` | `staging_status:` | SUCCESS→дальше; FAILED→откат (self-hosting) |
| `parse_security_status` | `17-security-report.md` | `security_status:` | PASS→дальше; FAIL→откат (FAIL авторитетен) |

`post_deploy.py` (`post_deploy_status:`, информационный) и `review_parse._strip_frontmatter`/
`security_gate.extract_security_findings` (извлечение прозы) переводятся на
`parse_frontmatter_dict` / `strip_frontmatter` соответственно — снимает оставшиеся дубли без
изменения их «never-raise → пусто» контрактов.

### D3. Валидатор: библиотека + warning-only, hard-fail строго под kill-switch

`validate_schema` — **чистая библиотечная функция** (INV-4, NFR-3). Чтобы гарантировать
**нулевую регрессию гейтов**, в default-режиме валидатор **не участвует в вычислении
boolean-вердикта** ни одного гейта. Вместо этого:

- Новый флаг `config.frontmatter_validation_strict: bool = False`
  (env `ORCH_FRONTMATTER_VALIDATION_STRICT`).
- **Default (`False`):** опциональный warning-emit — при чтении machine-verdict дока, не
  несущего полной схемы, единый хелпер `maybe_warn_schema(content, doc_label)` пишет
  `logger.warning("frontmatter schema incomplete: missing …")` и **возвращает управление без
  влияния на вердикт** (чистый no-op для `tuple[bool,str]`). Это удовлетворяет «по умолчанию
  warning/лог» (FR-2), оставаясь поведенчески инертным.
- **Strict (`True`):** зарезервированный режим будущего ужесточения (ORCH-52d+). Когда
  включён, тот же хелпер может вернуть гейту вето. На ORCH-52c флаг **остаётся `False`** в
  проде и в `.env.staging` — иначе задача self-block'нется (её доки без полной схемы). Strict
  покрывается unit-тестом, но не включается.

Решение «валидатор вне вердикт-пути по умолчанию» — осознанный выбор в пользу безопасности
self-hosting: машинная проверка схемы **существует и тестируется**, но **физически не может**
завалить гейт при дефолте.

### D4. Формальная спека handoff — `docs/_standards/HANDOFF_PROTOCOL.md`

Создаётся (на стадии development, как doc-deliverable) рядом с `PIPELINE_DOCS.md`. Структура
(нормативно для разработчика):

1. **Назначение + статус истины** — «источник истины поведения = код (`stages.py`,
   `qg/checks.py`, `stage_engine.py`); спека документирует» (правило ORCH-075).
2. **Обязательная frontmatter-схема** — таблица 6 полей (`work_item`, `stage`, `author_agent`,
   `status`, `created_at`, `model_used`) + смысл каждого; ссылка на `frontmatter.REQUIRED_FIELDS`
   как на машинный источник.
3. **Контракт handoff по стадиям** — для каждой стадии (`created`→…→`done`): какие документы
   **обязан** оставить выход стадии и какие frontmatter-ключи (machine-verdict ключ + будущая
   общая схема). **Согласовано 1:1 с `PIPELINE_DOCS.md` §2–§3** (тот же набор
   документов/ключей/гейтов; различие machine-verdict vs информационные сохранено).
4. **Перекрёстная ссылка** на единый API `src/frontmatter.py` и на флаг
   `frontmatter_validation_strict`.

`PIPELINE_DOCS.md` обновляется: блок «слой 1 описательный → ORCH-52c реализовала машинный
контракт» + ссылка на `HANDOFF_PROTOCOL.md` и на `src/frontmatter.py` (закрывает явную метку
«машинная проверка — отдельная задача ORCH-52c» в §5).

### D5. Без изменений API/БД/состава гейтов

Подтверждено INV-1/INV-5: HTTP-эндпоинты, `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, схема БД —
не трогаются. Опциональный счётчик валидации в `GET /queue` — **не вводим** (TRZ §4: не
требование; добавил бы поверхность без нужды).

---

## Альтернативы (отклонены)

- **A1. Общий «умный» verdict-резолвер** (одна функция читает поле+токены для всех 5 гейтов).
  Отклонено: token-наборы и правила различаются (особенно ORCH-047 3-поля + приоритет
  негатива); единая абстракция повысила бы риск тонкого регресса на гейте → недопустимо при
  self-hosting. Унифицируем только парс YAML (D2).
- **A2. Класс `Frontmatter`/новый пакет.** Отклонено: состояния нет, операции чистые; класс —
  лишняя церемония и больший blast radius. Функции в существующем leaf-модуле проще и
  безопаснее.
- **A3. Валидатор как hard-fail на гейте по умолчанию.** Отклонено прямо BRD/NFR-3: заблокирует
  собственный деплой ORCH-52c. Default — warning-only, hard-fail под флагом (D3).
- **A4. Сторонняя библиотека `python-frontmatter`.** Отклонено: новая зависимость ради ~30
  строк; `pyyaml` уже в проекте, формат тривиален, контроль над never-raise важнее.
- **A5. Ретро-фит схемы в существующие доки / правка промптов агентов.** Вне scope (это
  ORCH-52d, слой 3). Схема аддитивна и forward-looking.

---

## Последствия

**Плюсы**
- Единственная точка YAML-парсинга → конец рассинхрона обработки ошибок между гейтами.
- Writer + валидатор + полная схема готовы к ORCH-52d (агенты начнут эмитить схему).
- Спека handoff закрывает пробел «что стадия обязана оставить», согласована с манифестом.
- Нулевая поведенческая регрессия по построению: семантика и reason-строки 1:1, валидатор вне
  вердикт-пути при дефолте, never-raise сохранён.

**Минусы / ограничения**
- Унификация частичная (только парс, не семантика) — token-логика всё ещё живёт в каждом
  гейте. Это сознательный компромисс безопасности; полная унификация семантики — возможная
  будущая задача с отдельным риск-бюджетом.
- Strict-режим валидатора пока «спящий» (тестируется, но не включён) — реальная польза от
  enforcement появится только с ORCH-52d.
- Reason-строки нужно перенести **дословно** — за этим следит reviewer и анти-регресс-тесты.

**Обратимость**
- `frontmatter_validation_strict=False` (дефолт) ⇒ поведение эквивалентно прежнему.
- Перевод гейтов на `parse_frontmatter` поведенчески инвариантен; откат — точечный возврат
  inline-блока (но не требуется при зелёном регрессе).

**Тестирование (обязательно перед мержем)**
- `tests/test_frontmatter.py` (новый): reader (контракт неизменен), writer (round-trip
  `render → parse`), валидатор (полный/неполный набор, strict on/off), битый ввод → never-raise.
- Анти-регресс на каждый из 5 гейтов: старый док-вердикт **без** новой схемы → тот же
  `tuple[bool,str]`, что до задачи (NFR-1/AC-4); negative-token-приоритет tester'а (ORCH-047).
- Полный `pytest tests/ -q` зелёный.

## Связи
- Реализует: BR-1…BR-5, FR-1…FR-6, AC-1…AC-7.
- Опирается на: ORCH-075/52b (`PIPELINE_DOCS.md`, манифест), ORCH-016 (`frontmatter.py` reader),
  ORCH-047 (3-полевой tester-вердикт), ORCH-022 (security-гейт), ORCH-089 (`autoDeploy`).
- Готовит почву: ORCH-52d (агенты эмитят полную схему; возможное включение strict).
- Сквозной ADR: `docs/architecture/adr/adr-0020-frontmatter-contract.md`.
- Риски/инфра/данные: `10-tech-risks.md`, `07-infra-requirements.md`, `08-data-requirements.md`.
