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