feat(610): split the decision corpus into one YAML-frontmatter file per record #619

Merged
timothy merged 10 commits from feat/610-decisions-one-file-per-record into main 2026-07-25 20:36:08 +02:00
Owner

fixes #610

168 records → docs/decisions/records/<area>/<topic>.md (164 active across 23 dirs) and docs/decisions/archive/<area>/<topic>.md. The filename is the key, so one-active-record-per-key becomes a filesystem property rather than a validator check, and supersession becomes a git mv.

Read the harness, not the diff

This is a ~190-file diff. It cannot be meaningfully reviewed by reading it, and it isn't meant to be — correctness rests on a field-level equivalence proof:

168 records before and after · zero lost, zero gained, zero field mismatches, zero rationale bodies differing

The load-bearing design choice is that the parser was taught both formats first, dispatching on a leading ---. That means the body-diff guard parses the old form at the merge-base and the new form at head, both keyed on key — so the migration validates itself, with no bypass and no one-time escape hatch. decisions-validate --base origin/main --head HEAD exits 0 with the guard live (no Decisions-Edit trailer in the range).

The equivalence harness runs against a checked-in fixture, not the live corpus. The first version migrated the real tree, which made it a one-shot: the moment the migration landed there was nothing left to move and the tests failed for reasons unrelated to the code. A fixture keeps them testing the script rather than the repo's current state. The one-off proof against the real corpus was run separately and is quoted above.

What measuring caught that reading would not have

  1. ~500 lines sit outside any record — decisions.md's lifecycle schema and each topic file's preamble, mostly the only copy of that narrative. Source files are kept and stripped, never deleted. They also can't be filed per-area: topic files hold several areas, and 4 of 23 areas span several files.
  2. Archive discovery was a non-recursive glob. After the split it found zero archived records, surfacing as four bogus "supersedes points to unknown key" errors rather than an obvious failure.
  3. ~32 live docs point into the corpus by date (see decisions.md 2026-07-10), which the split dangles. Each stripped file now ends with a generated "Records formerly in this file" index — which also rescues the identical breadcrumbs in historical issue comments that cannot be rewritten at all.
  4. decisions.md's "In this file:" list was 97 same-file anchor bullets that the split makes wrong, not merely stale. Dropped; the generated index replaces them with links that resolve.
  5. .gitignore's [Rr]elease/ build-output rule silently swallowed records/release/ — all 9 release records were excluded from the first commit. Caught only by the body-diff guard reporting them as removed-without-archive. The directory is now explicitly un-ignored (negating the files alone wouldn't work — git never descends into an excluded directory). Same class as the core rule that swallows files under any Core/.
  6. The generated index heading is itself an H2, so the parser counted one unmigrated record per stripped file — the notice went 0 → 6 while the corpus still validated OK.

Deliberate, and stated rather than buried

  • Keys preserved verbatim, warts included. sched (12) and scheduling (1) are now two directories for one concept, alongside seven single-record areas. Renaming a key is not a move — it changes identity, breaks the equivalence proof, and invalidates MemPalace's per-key drawers. Taxonomy normalisation is separate work.
  • The budget now counts prose, excluding frontmatter. This flatters the number, so: it is a change of metric, not a consolidation. The corpus reads 5228/5600 — under budget for the first time — but no knowledge was removed. Consolidation is still outstanding and now has an owner: #620, filed precisely because a future session reading only the quiet warning would infer the corpus got consolidated when it did not. It is blocked-by #610, so it surfaces in the queue on merge.

Blocking follow-up before this is useful

MemPalace's exporter (server-management#642) must be updated in step — filed as server-management#663. Keys survive, so identity is intact, but paths and file format both change. An un-updated exporter does not error — it silently finds nothing, which is the exact failure mode the retrieval contract exists to prevent. Cross-repo, so it cannot land here; deliberately NOT a Done-when box on #610 because it is circular — an exporter cannot be verified against a layout that has not landed.

Verification

70 tests; ruff, format and pyright clean; decisions-validate: OK; catalog --check clean. Trial-run repeatedly against disposable worktree copies before touching the real corpus.

🤖 Generated with Claude Code

fixes #610 168 records → `docs/decisions/records/<area>/<topic>.md` (164 active across 23 dirs) and `docs/decisions/archive/<area>/<topic>.md`. **The filename is the key**, so one-active-record-per-key becomes a filesystem property rather than a validator check, and supersession becomes a `git mv`. ## Read the harness, not the diff This is a ~190-file diff. It cannot be meaningfully reviewed by reading it, and it isn't meant to be — **correctness rests on a field-level equivalence proof**: > 168 records before and after · **zero lost, zero gained, zero field mismatches, zero rationale bodies differing** The load-bearing design choice is that the parser was taught **both** formats first, dispatching on a leading `---`. That means the body-diff guard parses the *old* form at the merge-base and the *new* form at head, both keyed on `key` — so **the migration validates itself**, with no bypass and no one-time escape hatch. `decisions-validate --base origin/main --head HEAD` exits 0 with the guard live (no `Decisions-Edit` trailer in the range). The equivalence harness runs against a **checked-in fixture**, not the live corpus. The first version migrated the real tree, which made it a one-shot: the moment the migration landed there was nothing left to move and the tests failed for reasons unrelated to the code. A fixture keeps them testing the *script* rather than the repo's current state. The one-off proof against the real corpus was run separately and is quoted above. ## What measuring caught that reading would not have 1. **~500 lines sit outside any record** — `decisions.md`'s lifecycle schema and each topic file's preamble, mostly the only copy of that narrative. Source files are **kept and stripped**, never deleted. They also can't be filed per-area: topic files hold several areas, and 4 of 23 areas span several files. 2. **Archive discovery was a non-recursive glob.** After the split it found *zero* archived records, surfacing as four bogus "supersedes points to unknown key" errors rather than an obvious failure. 3. **~32 live docs point into the corpus by date** (`see decisions.md 2026-07-10`), which the split dangles. Each stripped file now ends with a generated **"Records formerly in this file"** index — which also rescues the identical breadcrumbs in historical issue comments that cannot be rewritten at all. 4. **`decisions.md`'s "In this file:" list was 97 same-file anchor bullets** that the split makes *wrong*, not merely stale. Dropped; the generated index replaces them with links that resolve. 5. **`.gitignore`'s `[Rr]elease/` build-output rule silently swallowed `records/release/`** — all 9 release records were excluded from the first commit. Caught *only* by the body-diff guard reporting them as removed-without-archive. The directory is now explicitly un-ignored (negating the files alone wouldn't work — git never descends into an excluded directory). Same class as the `core` rule that swallows files under any `Core/`. 6. **The generated index heading is itself an H2**, so the parser counted one unmigrated record per stripped file — the notice went 0 → 6 while the corpus still validated `OK`. ## Deliberate, and stated rather than buried - **Keys preserved verbatim, warts included.** `sched` (12) and `scheduling` (1) are now two directories for one concept, alongside seven single-record areas. Renaming a key is not a move — it changes identity, breaks the equivalence proof, and invalidates MemPalace's per-key drawers. Taxonomy normalisation is separate work. - **The budget now counts prose, excluding frontmatter.** This flatters the number, so: it is a change of *metric*, not a consolidation. The corpus reads 5228/5600 — under budget for the first time — but no knowledge was removed. **Consolidation is still outstanding and now has an owner: #620**, filed precisely because a future session reading only the quiet warning would infer the corpus got consolidated when it did not. It is blocked-by #610, so it surfaces in the queue on merge. ## Blocking follow-up before this is useful **MemPalace's exporter (server-management#642) must be updated in step** — filed as **[server-management#663](https://gitea.tblindustries.be/timothy/server-management/issues/663)**. Keys survive, so identity is intact, but **paths and file format both change**. An un-updated exporter does not error — it silently finds nothing, which is the exact failure mode the retrieval contract exists to prevent. Cross-repo, so it cannot land here; deliberately NOT a Done-when box on #610 because it is circular — an exporter cannot be verified against a layout that has not landed. ## Verification 70 tests; ruff, format and pyright clean; `decisions-validate: OK`; catalog `--check` clean. Trial-run repeatedly against disposable worktree copies before touching the real corpus. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
timothy added 7 commits 2026-07-25 19:16:45 +02:00
First step of the one-file-per-record split. `decisions_lib.parse_text` now
dispatches on a leading `---`: the legacy "many ## records per file, metadata on
a backtick line" form and the new "one record per file, YAML frontmatter" form
both produce the same Record.

This is the load-bearing move for the migration. Because the parser understands
both, the body-diff guard can parse the OLD form at the merge-base and the NEW
form at head -- both keyed on `key` -- so the commit that relocates 166 records
validates itself with no one-time bypass and no escape hatch.

Details:
- Frontmatter uses the SAME field vocabulary as the backtick line
  (`superseded-by`, `stale-after`), so on-disk names don't shift mid-migration.
- YAML `None` (a key with no value) is preserved as "" rather than collapsing to
  None, keeping the empty-vs-absent distinction `stale-after` depends on (#603).
- `active_files()` also walks `docs/decisions/records/**`, so both layouts can
  coexist while the migration lands.
- Malformed frontmatter (unterminated fence, non-dict, YAML error) yields no
  record rather than raising; the validator then reports it as a missing record.
The migration is a MOVE, so correctness is provable rather than reviewable:
parse the corpus before, migrate, parse after, assert the Record sets are
identical field-for-field with byte-identical rationale.

scripts/migrate_decisions_split.py
  166 records -> docs/decisions/records/<area>/<topic>.md (active) or
  docs/decisions/archive/<area>/<topic>.md (superseded/retired), 26 directories.
  Path is DERIVED from the key, so identity stays the key. Refuses to run if any
  record lacks a key, and aborts on a destination collision.

  Source files are KEPT, stripped to their narrative -- not deleted. 506 lines of
  the corpus sit outside any record: decisions.md's lifecycle-schema header (the
  status vocabulary, supersession rules, the edit-token contract) and each topic
  file's preamble explaining why those records exist. For most of it that is the
  only copy. It also cannot be filed per-area -- topic files hold several areas
  and 4 of 23 areas span several files -- so the files themselves stay.

scripts/tests/test_migration_equivalence.py
  Runs the real migration against a COPY of the real corpus in tmp_path, never
  the working tree. Asserts: all 166 records survive with the same keys, every
  metadata field round-trips, titles round-trip from headings, every rationale
  body is byte-identical, path matches key, archived records land in the archive
  wing, the legacy files keep their narrative, and no parseable record is left
  behind in them.

Proven non-vacuous: corrupting one migrated record's prose is caught by the
byte-identical check, and deleting one is caught by the survival check.

One test-authoring note: an early assertion string-matched "## " to prove no
records were left in decisions.md. That is wrong -- the schema header quotes an
illustrative "## 2026-07-17 ..." example in prose. Whether records remain is a
PARSING question, so the parser-based leftover test is the real invariant.
Both surfaced in a trial migration against a disposable copy of the real corpus,
not from reading:

- Archive discovery was a NON-recursive glob. After the split, archived records
  live at archive/<area>/<topic>.md, so every one of them became invisible --
  which surfaced as four bogus "supersedes points to unknown key" errors rather
  than as an obvious "no archive found". rglob at both sites.

- Catalog links: a record is now a FILE, so the link is a plain relative path
  with no anchor -- nothing to slug, nothing to keep in sync with a heading. The
  legacy anchor form is kept for records still living inside a multi-record file,
  so the catalog is correct on either side of the migration.
The line budget exists to bound how much narrative a reader or agent must get
through. Under the split each record carries ~11 frontmatter lines plus two
fences -- 1789 lines across 166 records -- which are the structured restatement
of what used to be one dense backtick line. Counting them inflates the metric
without any new knowledge being added.

Stated plainly because it flatters the number: this is a change of METRIC, not a
consolidation. It re-measures the same corpus, it does not shrink it. Whole-file
counting put the migrated corpus at 6837 against a 5600 budget; prose-only puts
the same content at ~5048. The consolidation work is still worth doing -- it is
simply no longer being signalled by a warning that was partly measuring
punctuation.

Inert pre-migration: no legacy file has frontmatter, so the branch is never
taken and today's number is unchanged.
The first cut tested `relative_to(TOPIC_DIR)`, which also matches the LEGACY
multi-record topic files -- so every record in workflow-process.md et al. lost its
anchor and linked to the top of the file instead of to its own record. Caught by
`build_decisions_catalog --check` going stale on the unmigrated corpus, not by
reading. Narrowed to `RECORDS_DIR in src.parents`, which by construction only
matches one-record-per-file.
Live docs point into the corpus BY DATE -- "see `decisions.md` 2026-07-10" --
about 32 such references across 12 files, plus the same form in historical issue
comments. The split would dangle every one of them.

Each stripped file now ends with a generated "Records formerly in this file"
index: date, title, and a link to the record's new path. A reader following a
date pointer lands on the file it names and resolves from there. That is far
cheaper and less error-prone than rewriting 32 references by hand, and it also
covers the issue-comment breadcrumbs, which cannot be rewritten at all.

Caught while verifying it: the generated `## Records formerly in this file`
heading is itself an H2, so the record parser counted one legacy-unmigrated
record per stripped file -- the notice went 0 -> 6. Same treatment as the
existing `## Index` section: skip it by name. SKIP_HEADINGS moved to
decisions_lib as the single source of truth, since three modules now need it.

Found by reading the validator's notice output on a trial migration, not by
inspection -- the corpus still validated OK, so nothing else would have flagged it.
Both surfaced when main gained two records mid-flight.

- test_record_count_is_the_expected_166 hardcoded the total, so it failed the
  moment a record landed on main -- a merge turning an unrelated test red. The
  real invariant is before == after; the count only needs to prove the harness
  isn't parsing a stub corpus, so it is now equality plus a floor.

- The migration's "lines preserved" figure silently absorbed the generated
  where-did-it-go index once that was threaded into the preamble string, jumping
  507 -> 759 with no new prose preserved. It now reports the two separately:
  514 lines of original prose, plus 245 generated index lines. A number that
  quietly changes meaning is worse than no number.
timothy force-pushed feat/610-decisions-one-file-per-record from 02a6e0c291 to fba5233caf 2026-07-25 19:45:14 +02:00 Compare
Author
Owner

Adversarial review — BLOCKED, then fixed and re-verified

First pass blocked on a real defect: 105 dangling links — in the very feature built to keep pointers resolvable.

_moved_index computed paths with relative_to(TOPIC_DIR), but the stripped files sit at three different depths: docs/decisions.md in docs/, the topic files in docs/decisions/, the archive files one deeper. So links resolved from exactly one of the three and dangled from the other two — including every link in docs/decisions.md, which is precisely the file the ~32 date-based pointers name.

And my test asserted existence from the wrong base, so it encoded the bug and green-lit it.

Five findings, all fixed at the class level

# finding fix
1 105 dangling links links now os.path.relpath(dest, src.parent) — relative to the file they live in
2 link test resolved from the wrong base walks every file with the index, resolves each link from f.parent, + vacuity guard
3 byte-identity proof was circular — baseline used mig._rationale_body, the same function the migration writes with baseline now uses a deliberately separate _independent_strip
4 .gitignore fix was a spot-fix (release/ only) nested docs/decisions/.gitignore with !*/ — fixes the class
5 _archive_records_at didn't filter SKIP_HEADINGS filtered, matching _records_at

Finding 3 is the one worth dwelling on: the reviewer proved it by making _rationale_body collapse interior blank lines — all 11 harness tests still passed. The output was always correct (it re-verified with its own independent stripper), but the proof apparatus was weaker than advertised. That's the same failure shape as a CI gate that merges green while doing nothing, which is now the third time this pattern has appeared in this work.

Re-review at fba5233c — each fix mutation-proven

  • Links: 168 generated links, 0 dangling; widened to all 341 relative links in docs/decisions* + docs/README.md, still 0. All three depths spot-confirmed.
  • Link test: restoring the old base makes it fail "3 dangling of 4" — the real-corpus pattern, proving the fixture exercises all three depths.
  • Circularity: the exact surviving mutation now fails 1/11 where it previously passed 11/11.
  • .gitignore: probed release, bin, obj, build, debug, x64, packages, logs, core, node_modules, dist, out, target under both wings — none ignored; all 175 files tracked, disk == index.
  • Regeneration delta: git diff 02a6e0c2..fba5233c -- docs/ touches exactly 7 files (the 6 with wrong link bases + the new .gitignore). All 169 record files byte-identical — the from-scratch re-run introduced nothing else.

The lossless-move proof re-holds

Both the reviewer's own comparator and an independent non-circular stripper: pre 168 / post 169 (+1 is this PR's own record), 0 lost, 0 gained, 0 field/heading mismatches, 0 body diffs, 134 interior-blank-line records intact.

70 tests; validator OK; catalog --check clean; --base origin/main --head HEAD exit 0 with the guard live.

Review-verdict: MERGEABLE @ fba5233caf

## Adversarial review — BLOCKED, then fixed and re-verified First pass **blocked** on a real defect: **105 dangling links** — in the very feature built to keep pointers resolvable. `_moved_index` computed paths with `relative_to(TOPIC_DIR)`, but the stripped files sit at **three different depths**: `docs/decisions.md` in `docs/`, the topic files in `docs/decisions/`, the archive files one deeper. So links resolved from exactly one of the three and dangled from the other two — including every link in `docs/decisions.md`, which is precisely the file the ~32 date-based pointers name. **And my test asserted existence from the wrong base, so it encoded the bug and green-lit it.** ### Five findings, all fixed at the class level | # | finding | fix | |---|---|---| | 1 | 105 dangling links | links now `os.path.relpath(dest, src.parent)` — relative to the file they live in | | 2 | link test resolved from the wrong base | walks every file with the index, resolves each link from `f.parent`, + vacuity guard | | 3 | **byte-identity proof was circular** — baseline used `mig._rationale_body`, the same function the migration writes with | baseline now uses a deliberately separate `_independent_strip` | | 4 | `.gitignore` fix was a spot-fix (`release/` only) | nested `docs/decisions/.gitignore` with `!*/` — fixes the class | | 5 | `_archive_records_at` didn't filter `SKIP_HEADINGS` | filtered, matching `_records_at` | Finding 3 is the one worth dwelling on: the reviewer proved it by making `_rationale_body` collapse interior blank lines — **all 11 harness tests still passed**. The output was always correct (it re-verified with its own independent stripper), but the *proof apparatus* was weaker than advertised. That's the same failure shape as a CI gate that merges green while doing nothing, which is now the third time this pattern has appeared in this work. ### Re-review at `fba5233c` — each fix mutation-proven - **Links**: 168 generated links, 0 dangling; widened to all 341 relative links in `docs/decisions*` + `docs/README.md`, still 0. All three depths spot-confirmed. - **Link test**: restoring the old base makes it fail "3 dangling of 4" — the real-corpus pattern, proving the fixture exercises all three depths. - **Circularity**: the exact surviving mutation now fails 1/11 where it previously passed 11/11. - **`.gitignore`**: probed `release, bin, obj, build, debug, x64, packages, logs, core, node_modules, dist, out, target` under both wings — none ignored; all 175 files tracked, disk == index. - **Regeneration delta**: `git diff 02a6e0c2..fba5233c -- docs/` touches exactly 7 files (the 6 with wrong link bases + the new `.gitignore`). **All 169 record files byte-identical** — the from-scratch re-run introduced nothing else. ### The lossless-move proof re-holds Both the reviewer's own comparator and an independent non-circular stripper: pre 168 / post 169 (+1 is this PR's own record), **0 lost, 0 gained, 0 field/heading mismatches, 0 body diffs**, 134 interior-blank-line records intact. 70 tests; validator OK; catalog `--check` clean; `--base origin/main --head HEAD` exit 0 with the guard live. Review-verdict: MERGEABLE @ fba5233caf44f41bf6601352f1c1b9f46e829262
timothy added 1 commit 2026-07-25 19:53:12 +02:00
feat(610): re-key the body-diff guard to key; enforce path<->key; pin the filesystem invariant
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 16s
PR Gates / decisions lifecycle (pull_request) Failing after 14s
PR Gates / Docs update reminder (pull_request) Successful in 19s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m24s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m19s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
52786a545c
Three Done-when items that were genuinely unfinished. I audited the checklist
before ticking it rather than after, and these were not done.

1. BODY-DIFF GUARD RE-KEYED FROM HEADING TO `key`. Heading-keying made a pure
   RENAME look like a removal plus an unrelated addition, so retitling a record
   failed CI as an "unlogged removal" -- a trap that has bitten this repo before.
   Records are now MATCHED by key and REPORTED by heading (a key alone is not
   enough for a human to find the record). Demotion, which is inherently about a
   record losing its key, is detected on the heading axis instead, and a demoted
   record is no longer double-reported as removed. Key-matching is also what lets
   the comparison work straight through the legacy->frontmatter migration, since
   `key` survives both the format change and the move between files.

   Pinned by a test driving the REAL git-backed diff engine: retitle a record,
   same key, byte-identical prose -> removed/rewritten/demoted all empty.
   Mutation-verified: restoring heading-keying makes it fail with exactly
   "a retitle was reported as a removal".

2. PATH <-> KEY ENFORCED BY THE VALIDATOR. The filename is derived from the key,
   which is what makes one-active-per-key a filesystem property rather than a
   check -- but only if the two cannot drift. Now an error when they disagree,
   with legacy multi-record files explicitly exempt (they have no key-derived
   path to match).

3. ONE-ACTIVE-PER-KEY PINNED AS A FILESYSTEM PROPERTY. Two records sharing a key
   derive the same path, so the filesystem refuses the second; the migration
   aborts on a destination collision rather than silently overwriting. Both are
   now tested, the latter end-to-end through `main()`.

Also fixed a vacuous assertion ruff caught in my own new test -- `assert X or
True` is always true.

Note on verification: my first positive control for the guard reported it NOT
firing. That was the probe, not the code -- "load 340" appears in both the
`sources:` frontmatter field and the body, and replace(...,1) hit the metadata
copy, which is correctly token-free. Re-run against genuine body prose, the guard
fires with exit 1.

refs #610
timothy added 1 commit 2026-07-25 19:57:25 +02:00
fix(610): make the frontmatter READ path dependency-free — CI has no PyYAML
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 17s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m15s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 16m41s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
02c82b35ea
This is why `decisions lifecycle` went red, and it was NOT the known flake. I
came close to dismissing it as one for the second time this session, because an
earlier red on another branch genuinely was.

The dual-format parser imported PyYAML to read frontmatter. `decisions-guard`
does `setup-python` and installs NOTHING, so once the corpus was migrated every
record became unparseable there: ModuleNotFoundError, job fails. The same would
hit the Husky pre-commit hook and every contributor's machine.

Installing PyYAML in CI is the wrong fix: READING happens everywhere -- CI, the
hook, every dev -- while WRITING happens once, in a migration a human runs
deliberately. So the read path is now dependency-free and only
`migrate_decisions_split` (the writer) still imports yaml.

A hand-rolled parser is only safe if it provably matches the library that WROTE
the files, so `test_frontmatter_reader_matches_pyyaml_on_every_real_record`
compares the two field-by-field across all 169 real records (importorskip, so it
is skipped rather than failing where PyYAML is absent) with a >100-file guard
against near-vacuity. It is narrow by construction: the frontmatter is
machine-generated with default_flow_style=False and width=10**9, so every value
is a single-line scalar, and the reader bails to None on anything nested.

Verified by running all four affected entry points against a shim that makes
`import yaml` raise: validate --base/--head, catalog --check, the kickoff guard,
and the plain validate the pre-commit hook calls. All exit 0.

refs #610
Author
Owner

Re-review of the final two commits — MERGEABLE

The verdict on record was stale (fba5233c), and the two commits since included a change to core guard behaviour, so they got their own scoped review rather than riding the old verdict.

The riskiest change survived every escape attempt

Re-keying record matching from heading to key was the thing most likely to silently weaken the guard. Attacked with a byte-identical scenario battery through the real _diff_findings:

scenario result
key renamed, same heading + body demoted — blocks
key + heading + body all changed removed — no escape via total identity swap
archive-move + retitle + rewrite (laundering) rewritten — caught
archive-move + retitle, body byte-kept clean, correctly
retitle only clean — the trap it was built to retire

The laundering row is the notable one: that case escaped nothing under key-matching, and heading-matching could not have body-checked it at all. The re-key closed a blind spot rather than opening one.

Fallback-key collision is structurally impossible — a real key must match ^[a-z0-9]+(\.[a-z0-9-]+)+$ and so cannot contain :, so heading:-prefixed fallbacks can never collide. No bypass found for the path⇔key exemption either.

#609's work is intact

_edit_trailer_armed extracted at 798ecd1f and at head is byte-identical (2986 = 2986 bytes), with --no-merges, unfold, valueonly, affirmative-only and the fail-closed return False all present.

The hand-rolled reader held up better than expected

Adversarial probes came out in its favour versus PyYAML on the cases that actually matter for this corpus: unquoted key: then path colon-space (PyYAML hard-errors → record invisible; mine parses), mid-value #406 (PyYAML truncates at the hash; mine keeps it), yes/null (PyYAML mangles to True/""; mine stays literal), unquoted dates, duplicate keys, empty and quote-only values. Plus an independent 169-record differential against the pre-migration backtick corpus: 0 field mismatches, 0 body diffs.

Two divergences found, both future-facing and neither affecting existing content:

  1. A record file the strict reader rejects is silently invisible — filed as #621. A block-scalar rule: >- (the natural thing to reach for on long values) parses to [], and validator + catalog both report success. Existing records disappearing stays loud; this only bites a newly added record. The fix is a structural "every file under the record wings parses to exactly one keyed record" check, which makes every parse-failure mode loud at once.
  2. Trailing YAML comments glue into an unquoted value — only reachable by hand-writing a comment, degrades to noise.

Finding 1 is thematically the residual I like least — it is the same class as the #609 marker bug and the #603 stale-after bug: something that reports success while doing nothing. It is owned in #621 rather than silently accepted, and it does not widen risk to anything currently in the corpus.

CI all 9 green; 76 tests; validator OK; catalog --check clean; --base origin/main --head HEAD exit 0 with the guard live.

Review-verdict: MERGEABLE @ 02c82b35

## Re-review of the final two commits — MERGEABLE The verdict on record was stale (`fba5233c`), and the two commits since included a change to **core guard behaviour**, so they got their own scoped review rather than riding the old verdict. ### The riskiest change survived every escape attempt Re-keying record matching from heading to `key` was the thing most likely to silently weaken the guard. Attacked with a byte-identical scenario battery through the real `_diff_findings`: | scenario | result | |---|---| | key renamed, same heading + body | `demoted` — blocks | | key + heading + body all changed | `removed` — no escape via total identity swap | | archive-move + retitle + rewrite (laundering) | **`rewritten` — caught** | | archive-move + retitle, body byte-kept | clean, correctly | | retitle only | clean — the trap it was built to retire | The laundering row is the notable one: that case **escaped nothing** under key-matching, and heading-matching could not have body-checked it at all. The re-key closed a blind spot rather than opening one. Fallback-key collision is structurally impossible — a real key must match `^[a-z0-9]+(\.[a-z0-9-]+)+$` and so cannot contain `:`, so `heading:`-prefixed fallbacks can never collide. No bypass found for the path⇔key exemption either. ### #609's work is intact `_edit_trailer_armed` extracted at `798ecd1f` and at head is **byte-identical** (2986 = 2986 bytes), with `--no-merges`, `unfold`, `valueonly`, affirmative-only and the fail-closed `return False` all present. ### The hand-rolled reader held up better than expected Adversarial probes came out **in its favour** versus PyYAML on the cases that actually matter for this corpus: unquoted `key: then path` colon-space (PyYAML hard-errors → record invisible; mine parses), mid-value `#406` (PyYAML truncates at the hash; mine keeps it), `yes`/`null` (PyYAML mangles to `True`/`""`; mine stays literal), unquoted dates, duplicate keys, empty and quote-only values. Plus an independent 169-record differential against the pre-migration backtick corpus: **0 field mismatches, 0 body diffs**. Two divergences found, both future-facing and neither affecting existing content: 1. **A record file the strict reader rejects is silently invisible** — filed as **#621**. A block-scalar `rule: >-` (the natural thing to reach for on long values) parses to `[]`, and validator + catalog both report success. Existing records disappearing stays loud; this only bites a *newly added* record. The fix is a structural "every file under the record wings parses to exactly one keyed record" check, which makes every parse-failure mode loud at once. 2. Trailing YAML comments glue into an unquoted value — only reachable by hand-writing a comment, degrades to noise. Finding 1 is thematically the residual I like least — it is the same class as the #609 marker bug and the #603 `stale-after` bug: something that reports success while doing nothing. It is owned in #621 rather than silently accepted, and it does not widen risk to anything currently in the corpus. CI all 9 green; 76 tests; validator OK; catalog `--check` clean; `--base origin/main --head HEAD` exit 0 with the guard live. Review-verdict: MERGEABLE @ 02c82b35
timothy merged commit 8e84191a03 into main 2026-07-25 20:36:08 +02:00
timothy deleted branch feat/610-decisions-one-file-per-record 2026-07-25 20:36:09 +02:00
Sign in to join this conversation.