Files
orchestrator/docs/work-items/ORCH-076/06-adr/ADR-001-frontmatter-contract.md

19 KiB
Raw Permalink Blame History

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.


Статус

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_loadisinstance(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 (имена канонические; точные дефолты — в реализации, контракт фиксирован здесь):

# --- константы схемы ---
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.