Files
ersatztv/docs/decisions/records/docs/record-wing-parse-guard.md
T
timothyandClaude Opus 5 9949703585
Review verdict / Set review-verdict status (pull_request) Successful in 4s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 19s
PR Gates / Docs update reminder (pull_request) Successful in 21s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m38s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 11s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m20s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 17m45s
review-verdict/h10 Review-verdict: MERGEABLE @ 9949703
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
docs(621): state the mutation count precisely — it depends on which mutation
The record said "10 tests go red"; on the rebased base a full short-circuit reddens 11.
Both numbers are real and measure different mutations: neutering only the scan loop
leaves the empty-wing check live (10), while returning [] from the whole function
disables that too (11). The reviewer flagged the phrasing as ambiguous before the count
also drifted.

Now states the mutation alongside the number, since a bare count is exactly the kind of
frozen figure this session has watched rot four times.

Refs #621

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 12:05:09 +02:00

5.1 KiB

key, title, status, since, supersedes, superseded-by, rule, signals, mechanics
key title status since supersedes superseded-by rule signals mechanics
docs.record-wing-parse-guard 2026-07-26 — Every file in the record wings must parse to exactly one keyed record; the frontmatter reader stays flat-scalar-only (#621) active 2026-07-26 none none `decisions_validate.py` asserts, per PATH, that every `*.md` under `docs/decisions/records/**` and `docs/decisions/archive/**` parses to exactly one record carrying a `key` — an ERROR, not a warning, since a file in the record wings that is not a record is a mistake by definition. A file sitting DIRECTLY in `archive/` is exempt only when it actually looks like a #610 stripped index — exactly one keyless record with a known generated heading — never merely by living there. The one other exemption, `archive/README.md`, is by exact RELATIVE PATH; nothing is ever exempt by BASENAME, since that would exempt the same filename in the active wing too. `_read_frontmatter` is deliberately NOT extended to accept YAML block scalars: every record value goes on ONE line, and the structural check is what makes that limitation loud instead of silent. record silently invisible, parsed to 0 records, block scalar rule >- , unterminated frontmatter, file vanishes from corpus, validator OK but record absent, catalog up to date but missing, one keyed record per file, stripped legacy archive index exempt · paths: `scripts/decisions_validate.py`, `scripts/decisions_lib.py`, `scripts/tests/test_decisions_validate.py` · issues: #621, #610, #609, #603 `scripts/decisions_validate.py` -> `record_wing_files` / `record_wing_faults`; `docs/ci-cd.md` -> "`decisions-guard` job"

Found in adversarial review of #610 / PR #619. Nothing enforced that a file under the record wings is actually a record. A file the dependency-free frontmatter reader cannot parse returns [] and simply vanishesdecisions_validate.py prints OK, build_decisions_catalog.py --check says up to date, the record is absent from the corpus, and there is no error anywhere.

This is the corpus's own failure mode turned on itself. The whole point of the decision system is retrievability; the one thing worse than a missing record is a missing record that reports success. Same family as the #609 marker that printed OK while doing nothing and the #603 stale-after field that silently never fired.

What was already safe, and what wasn't. An existing record disappearing was always loud — the no-vanish diff check catches it. The hole is a newly added record, which the diff check structurally cannot see because there is no base state to compare against, so the author's own PR looks clean. That asymmetry is why the fix had to be path-driven rather than diff-driven.

Why per-path rather than per-construct. Enumerating the YAML the reader rejects would have to be re-done every time the reader meets something new. Asserting "this path must yield exactly one keyed record" converts a whole CLASS of reader limitations from silent to loud in one move — block scalars, indented structure, unterminated frontmatter, a stray note file, an empty file. It does NOT catch parse-to-WRONG (frontmatter yielding one keyed record with corrupted values); a separate junk-key check covers the realistic instance of that, so this is a strong guard, not a total one.

The block-scalar decision: NO. rule: >- is the natural thing to reach for on this corpus's very long rule: values, and under PyYAML it parsed fine — so #610's dependency-free reader widened a hole rather than creating it. Teaching _read_frontmatter to fold block scalars was still declined:

  • Correct folding is a real subset of YAML (> vs |, strip/clip/keep chomping, indentation-relative continuation). A subtly wrong folder is worse than a rejection, because it would silently alter rule text — the exact class of failure this record exists to close — while test_frontmatter_reader_matches_pyyaml (which compares against PyYAML on real records) would keep passing on the flat records that make up the entire corpus.
  • The reader must stay dependency-free: it runs in CI's decisions lifecycle job, in the Husky pre-commit hook, and on every contributor's machine, none of which install anything.
  • With the structural check in place the cost of not supporting block scalars is a loud error naming the fix, and all 168 record files already keep each value on ONE line. (The invariant is single-line, not single-quoted: 117 rule: values are unquoted plain scalars, 51 are quoted.) There is no value a flat scalar cannot hold.

Verified by mutation, not by a green tick: with record_wing_faults short-circuited to return [], 11 tests go red (10 if only the scan loop is neutered, leaving the empty-wing check live), and separately, deleting the single line that WIRES it into main() reddens a dedicated test — that one line's removal previously left the whole suite green while a real record vanished, which is the guard's own failure mode applied to the guard. test_real_repo_record_wings_are_all_parseable additionally asserts the wings are non-empty (>100 files) so a clean result can never be vacuous.