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
~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.
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.
~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.
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.
.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/.
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.
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)
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.
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.
## 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
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
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
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.
_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:
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.
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 #603stale-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.
## 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 main2026-07-25 20:36:08 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
fixes #610
168 records →
docs/decisions/records/<area>/<topic>.md(164 active across 23 dirs) anddocs/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 agit 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:
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 onkey— so the migration validates itself, with no bypass and no one-time escape hatch.decisions-validate --base origin/main --head HEADexits 0 with the guard live (noDecisions-Edittrailer 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
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.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.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..gitignore's[Rr]elease/build-output rule silently swallowedrecords/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 thecorerule that swallows files under anyCore/.OK.Deliberate, and stated rather than buried
sched(12) andscheduling(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.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--checkclean. Trial-run repeatedly against disposable worktree copies before touching the real corpus.🤖 Generated with Claude Code
02a6e0c291tofba5233cafAdversarial 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_indexcomputed paths withrelative_to(TOPIC_DIR), but the stripped files sit at three different depths:docs/decisions.mdindocs/, the topic files indocs/decisions/, the archive files one deeper. So links resolved from exactly one of the three and dangled from the other two — including every link indocs/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
os.path.relpath(dest, src.parent)— relative to the file they live inf.parent, + vacuity guardmig._rationale_body, the same function the migration writes with_independent_strip.gitignorefix was a spot-fix (release/only)docs/decisions/.gitignorewith!*/— fixes the class_archive_records_atdidn't filterSKIP_HEADINGS_records_atFinding 3 is the one worth dwelling on: the reviewer proved it by making
_rationale_bodycollapse 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-provendocs/decisions*+docs/README.md, still 0. All three depths spot-confirmed..gitignore: probedrelease, bin, obj, build, debug, x64, packages, logs, core, node_modules, dist, out, targetunder both wings — none ignored; all 175 files tracked, disk == index.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
--checkclean;--base origin/main --head HEADexit 0 with the guard live.Review-verdict: MERGEABLE @
fba5233cafkey; enforce path<->key; pin the filesystem invariantRe-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
keywas the thing most likely to silently weaken the guard. Attacked with a byte-identical scenario battery through the real_diff_findings:demoted— blocksremoved— no escape via total identity swaprewritten— caughtThe 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:, soheading:-prefixed fallbacks can never collide. No bypass found for the path⇔key exemption either.#609's work is intact
_edit_trailer_armedextracted at798ecd1fand at head is byte-identical (2986 = 2986 bytes), with--no-merges,unfold,valueonly, affirmative-only and the fail-closedreturn Falseall 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 pathcolon-space (PyYAML hard-errors → record invisible; mine parses), mid-value#406(PyYAML truncates at the hash; mine keeps it),yes/null(PyYAML mangles toTrue/""; 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:
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.Finding 1 is thematically the residual I like least — it is the same class as the #609 marker bug and the #603
stale-afterbug: 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
--checkclean;--base origin/main --head HEADexit 0 with the guard live.Review-verdict: MERGEABLE @
02c82b35