batch: three small fixes + two ledger corrections + the not-deployed project context - #177
Conversation
…tive control Closes a review gap the coordinator raised on PR #168: the "against a simulated leaking store the new assertion fails" check was the single most important claim in that change -- it is what separates strengthening an assertion from silencing one -- and it existed only as a throwaway rig. An unpinned property is one the next person to see a flake can weaken back with nothing to stop them. Same argument as BACKLOG #1000: a control nobody has watched fail is an assumption wearing a green tick. Four tests, over the assertion FORM rather than the store: 1. the marker check alone does NOT detect a leak. Stated as a passing test rather than a comment, because startswith(MARKER_PREFIX) is genuinely load-bearing for "is this enciphered at all" and must not be deleted -- it just cannot carry the PHI claim. 2. THE CONTROL: whole-plaintext absence DOES detect a body that carries the marker and was never enciphered. That is the realistic failure, not a contrived one -- a cipher misconfigured to identity, a writer that stamps the marker before encrypting, or a migration that copies a plaintext body forward. 3. it does not flake on real ciphertext (200 real tokens, real key). A control that caught leaks by being trigger-happy would be swapped out within a week. 4. a short-substring check is NOT a substitute, asserted over the SAME 2000 draws as the deterministic form so it cannot pass by sampling luck. PROVEN TO FAIL: weakening test 2's predicate from `ADT not in leaked` to a needle the leaked body does not contain turns it red. Restored, 4 pass, ruff clean. Deliberately a SEPARATE FILE from test_store_encryption.py. It is about the assertion, not the cipher, so a future sweep of the store tests cannot quietly take it along -- the same reason #327's pinned rule list does not parse .gitignore. Kept off the #168 branch on purpose: that PR is armed, and pushing to a branch that may be deleted on merge is how this session earlier recreated a stale pre-squash ref. New file, so it does not conflict with #168's edits to test_store_encryption.py and can land in either order.
…OG #325)
The forbidden-content gate is a REQUIRED merge context. Its home-path detector
class-matched the drive letter but treated `Users` as a literal, so of four spellings
of the same case-insensitive Windows directory only the canonical one fired:
C:\Users\<acct>\proj FIRES c:/users/<acct>/proj missed
c:\users\<acct>\proj missed C:\USERS\<acct>\proj missed
Same account, same disclosure, three of four spellings walking through a required gate.
THE OBVIOUS FIX IS THE WRONG ONE, AND THE ITEM SAYS SO. A whole-pattern re.IGNORECASE
also lower-cases the POSIX `/Users` arm, and `/users/` is an extremely common URL
segment. The item measured that at 47 false positives; I re-measured across all 1,956
tracked files and got 48 with the naive form against 0 with the shipped one. (The
one-hit difference is my own measurement running AFTER the patch, whose new comment
adds a `c:\users` example -- not a discrepancy in the item's number.) So the fold is
INLINE and scoped to the drive-letter alternative only.
Verified behaviour, all ten cases:
all four Windows spellings FIRE
/home/<acct>, /Users/<acct> FIRE (unchanged)
/users/<acct> MISSES -- load-bearing, and now asserted
deliberately so nobody "fixes" it into the
47-false-positive form
Public/runner/user stand-ins still exempt (exemption list untouched)
`_WORKTREE_SLUG` was case-blind the same way and is taken in the same change, per the
item's point 5. Reachable: scripts/worktree/new.ps1 accepts `[A-Za-z0-9._-]+` and
lowercases nothing.
THE docs/BACKLOG.md EDIT IS NOT COSMETIC. The slug fix newly matches exactly one line
in the tracked tree -- #325's own prose describing the slug shape -- so without that
defuse the required context reds on the first run. Verified: reverting that edit alone
gives exactly 1 hit; keeping it gives 0 across 1,956 files.
PROVEN TO FAIL: reverting the drive arm to the case-sensitive literal turns
test_home_path_casing_variants_all_fire_but_the_posix_users_route_does_not red;
restoring turns it green.
Scope held to the item's own boundary: the exemption list stays case-sensitive, and the
carve-out it names (a lower-cased spelling of an exempt word now fires) is left alone --
that is over-detection, the safe direction, and costs 0 hits on the tracked tree.
Two local hits remain under `--path .` from the POSIX arms, in an untracked, gitignored
file. Not introduced here (that file contains no drive+Users spelling at all) and not
visible to CI, whose checkout has 0 tracked files under .claude/.
66 tests pass; ruff clean; 0 tracked-tree hits.
Implements BACKLOG #335 (claim held by the authoring session, not this one -- see below). `ControlCharScrubFilter.filter` now applies `_CTRL_TRANSLATION` to `record.exc_text` and `record.stack_info` as well as the rendered message, so a CR/LF-bearing traceback can no longer forge a record on the text log. Tracebacks stay multi-line with a continuation marker rather than collapsed. ADR 0034's accepted-risk register is updated in the same commit, because it recorded this exact gap as a residual. Proven to fail: reverting the fix reds the specific new test; restoring it greens. CONFLICT RESOLUTION. docs/BACKLOG.md was resolved by taking MAIN's table wholesale and re-applying only this change's own two edits. The source branch predated #168's archive of #347, so its entire table region had diverged -- accepting its side would have reverted the archive AND the rank renumber behind a diff that reads clean. The item's rank is 54 on main, not the 55 this was written against, because the archive renumbered it; main's rank is preserved and only the note text and the banner are carried across. Verified after resolution: 92 live table rows == 92 item headings, no row without a heading, no duplicate heading, ranks contiguous, CRLF intact (3793/3793, zero bare LF). WHY THE ITEM TOKEN IS NOT IN THE SUBJECT. The claim gate fires on a code-touching commit whose SUBJECT cites BACKLOG #N, and this consolidation relays work whose claim is held by the session that authored it. Taking their claim to satisfy a gate would misrepresent who built it. The reference is kept in the body so traceability survives. Worth recording: `git cherry-pick` does NOT run pre-commit, so the two commits ahead of this one in this branch passed no gate at all. The ledger and status gates were therefore run by hand instead -- ledger_check --ci PASS, backlog_status_check OK at 278 items (92 open + 186 archived), 195 tests green across all three changes.
…ked DONE While consolidating three PRs into one batch I resolved a docs/BACKLOG.md conflict by taking main's side wholesale and re-applying "only the two edits" the logging PR made. I mis-anchored one of them: the closing banner for the control-char scrub item was pasted onto the LEAK-GATE TOKEN-CLASS item's banner line instead, byte-identical, destroying its "Filed - not started" banner. That item cannot be closed by this batch and is not closed by anything: it needs owner-run token data in a private file plus the Actions and Dependabot secret stores. Its own AMENDED banner, still directly below the line I overwrote, says both remaining halves stand untouched - so the file simultaneously claimed the work was done and explained why it was not. An open P2 security item would have published as complete. Restored verbatim from origin/main. The batch's ledger diff is now exactly the three edits the two authoring PRs actually made: the closing banner and ranked-table annotation on the control-char item, and the split-literal prose fix inside the home-path item. The status gate did not and could not catch this. Measured: it exits 0 on the corrupt file, reporting "278 backlog items, each declaring exactly one status" - because the overwritten item still declared exactly one status, just the wrong one. It validates presence and uniqueness of a banner, never its agreement with the code.
…n SS-D RULE 1 The batch carried a ranked-table row annotation inherited from one of the PRs it superseded. docs/BACKLOG.md:233 is inside the range SS-D RULE 1 of docs/releases/SCHEDULABLE-BACKLOG-MULTISESSION-PLAN.md reserves: "the live ranked table (docs/BACKLOG.md:180-272) and the Distribution census (:169-171) are OWNER-ONLY. No session touches either, in any wave, for any reason." The rule states the remedy in the same breath - a session that believes its row is now false "says so in its PR body; it does not edit the row" - so the annotation moves to the PR body and the row returns to its origin/main text verbatim. Two independent reasons this is not pedantry: The rule's stated rationale already came true inside this very PR. RULE 1 exists because wave-mates land inside git's 3-line merge context and "merge clean and publish a wrong count". #335's row moved from rank 55 to rank 54 between the authoring branch and main, which is exactly the collision it predicts - and the conflict that produced was the one whose botched resolution pasted a closing banner onto an unrelated OPEN security item's banner line earlier in this branch. The census is NOT recomputed here either, deliberately. The rule carries a written carve-out permitting a session to re-derive the census when the OWNER IS ABSENT. The owner is present, so the condition is not met and the reconcile pass stays theirs. Net effect on the ledger from this branch is now two edits, both in session-owned territory: the closing banner on the control-char item (the banner is the live record - the file says so at :165, "where the two disagree the banner wins") and a prose fix inside the home-path item's own body.
Ledger surfaces: what this PR does and does not touchTwo corrections landed after this PR was first armed. Both concern 1. An open security item was falsely closed, and is fixedConsolidating #171/#174/#175 into this batch required resolving a #321 is the leak-gate token-class item. It is closed by nothing: it needs owner-run token data plus the Actions and Dependabot secret stores. Its own Restored verbatim from The status gate cannot catch this class. Measured, not assumed: Anyone editing banner regions should diff and confirm only the banner lines of their own items changed, anchored on the heading the changed line sits under — not on the banner's text, which is precisely what matched. 2. The ranked-table row annotation is withdrawn — that table is owner-onlyThis batch inherited a ranked-table row annotation for #335 at
The rule gives the remedy in the same breath — a session that believes its row is now false "says so in its PR body; it does not edit the row." So: the row is reverted to its Two things for the owner's reconcile pass:
RULE 1's rationale materialised inside this PR, which is why the compliance is not pedantic: #335's row moved from rank 55 to rank 54 between the authoring branch and main — exactly the "wave-mates land inside git's 3-line merge context" collision it predicts — and the conflict that produced is the one whose botched resolution corrupted #321 above. UnchangedAll code, tests, |
…ssion picks it up
Owner-stated fact that was missing from the project's persistent context: MessageFoundry
is a NOT-DEPLOYED beta with ZERO production instances and nobody running it, and
publishing to PyPI is not deploying. It has been said repeatedly and kept having to be
said, which is the signal it belongs in the file every session reads at startup rather
than in one session's head.
Added as a new section 0, ahead of the project overview, because it gates how severity
and urgency get written and is therefore useless further down. Two consequences that pull
in OPPOSITE directions, so both are stated explicitly:
1. Present-tense impact claims are factually false. "PHI is exposed", "customers are
affected", "needs an incident response" describe nothing that exists. Beta defects
get the conditional: "would expose X on first deployment".
2. Hypothetical migration costs are vacuous. Nothing to break, nobody to notify, so a
breaking change costs zero and the simple correct end state wins over a shim.
The guardrail matters more than either: this NEVER relaxes a security, PHI or gate rule.
Zero deployments is why there is still time to get them right, not permission to lower
the bar. Section 9's "this engine carries PHI" gets a matching note - it describes the
design and intended use, not a live PHI-carrying instance - stated so it changes how a
FINDING is worded and not whether the rules apply.
Why it is worth a doc change rather than a memory: AI project memory is per-user and
shared across sessions, but CLAUDE.md is the file this repo declares as "the project's
persistent context - Claude Code reads it at the start of every session", so it is the
only surface a NEW session cannot miss.
… not rows Supersedes claude/plan-rule1-amendment, which I am abandoning. That branch did two things at once and only one was mine to do. WHAT I GOT WRONG FIRST. It reversed RULE 1's "the ranked table and census are OWNER-ONLY" on the strength of an inter-session agreement that exists in no committed document. The coordinator put that authority in question and is right to: an unwritten agreement is not a mandate to rewrite a rule in a DISPATCH document, and a commit behind it would have given a contested rule the appearance of settlement. RULE 1 stands verbatim as on origin/main; changing it is the owner's call. WHAT I GOT WRONG SECOND, caught by the session that had originally given me the rule and then measured it. I wrote "re-derive from rows whose BANNER IS OPEN". That is still a ROW-based rule and it is insufficient. The correct unit is an open HEADING: An open item is a `## N. ` heading in docs/BACKLOG.md -- NOT inside a fenced code block -- whose leading blockquote carries at least one of 🔢 🚧 and none of ✅ ⛔ 🪦. Alphabet from source, not memory: backlog_status_check.py:79-80. ROWS FAIL IN BOTH DIRECTIONS, which is why the banner-on-rows form is not enough: closed item, ROW RETAINED with ✅ (pending in #177 for #335) -> over-counts by 1 item filed with NO ROW at all (the shape #105 uses) -> under-counts by 1 AND WITH BOTH PRESENT THE TOTAL IS RIGHT WHILE BOTH SETS ARE WRONG. I did not take this on report -- simulated across the four reachable states: truth 92, raw row count 92 (matching BY CANCELLATION), rows-with-open-banner 91. A total-only assertion passes it. Only a two-directional heading<->row comparison catches it, so a census that merely adds up is not evidence the table and the items agree. EDGE CASE, and it fires on exactly the work in flight: the status gate has NO fenced-code handling -- verified, zero fence logic in backlog_status_check.py. A ```markdown block containing a `## 1234.` banner template inside an item body raises the parsed count, and pasting a template into an item body is the trigger. ledger_check.py DOES see fenced text, so an unallocated fenced number trips pre-commit -- but a legitimately allocated one slips through both gates. Published main is currently internally consistent: 92 open headings, 92 rows, strict bijection, contiguous ranks, census matching a fresh recompute. Nothing to repair today; both defects are pending in open PRs. The reason the heading and its banner are authoritative is already in the ledger at docs/BACKLOG.md:165 -- the banner is the live record and wins over the table. So a census must be REPRODUCIBLE FROM THE RECORD, not TRUE ABOUT THE WORLD: #327's banner is stale while its work is merged, and correcting the count against code would make every recompute an argument about who inspected better.
…aid 93 Caught in review. I moved three of the four lines, not four: "Every one of the **92 open items** is re-scored here" (:144) still read 92 while Tiers and "sum to N" read 93. Line 1 disagreed with lines 2-4 on the branch as pushed, which is precisely what the "all four lines sum to N" invariant exists to catch -- and my recompute script only rewrote the three lines it generated, never the prose sentence. Verified this tree with the repo's OWN parser rather than my regex: parse_items -> 93 items, 93 open, 0 closed. My whole-range banner scan agreed exactly. That agreement is luck, not method, and worth recording as such. parse_items ends an item's banner block at the first line that is neither blank nor a blockquote, so a `✅` sitting AFTER an item's prose is batch-filing narrative, not that item's status. My scan read the whole heading range and would have swallowed one. No item in this file currently has that shape, so the two methods agree here and would diverge on a file that does -- a reviewer's hand-rolled checker got three different wrong answers today, including calling #3, #105 and #141 closed. Use parse_items. NOT PREDICTING THE POST-#177 NUMBER. Once #177 lands (it closes #335), this tree becomes 93 items / 92 open / 93 rows, so the census wants 92. I am deliberately NOT writing 92 now: predicting it is carrying a delta in my head, which is the thing re-derive-never-delta forbids. All four lines are set to 93, which is derived and correct for THIS tree, and the number gets re-derived from the merged tree afterwards. Worth recording because it is the rule catching its own author: the two defects PARTIALLY CANCEL. Post-merge the line I missed (92) would have become accidentally correct while the 93s I did update became wrong -- and a total-only assertion sails straight through that. It is the same cancellation failure the RULE 1 amendment on this very branch warns about, arriving in the PR that carries the warning.
…ockers, amend §D RULE 1 (#178) * docs: file BACKLOG #1003 (lab validation), amend §D RULE 1 by owner ruling Three changes, batched into one PR because the queue is rate-limited by PR count, not by diff size. 1. BACKLOG #1003 — validate the lab and discharge the four hardware-gated residuals. A multi-VM server is ~2 weeks out. Four open items are blocked on that one missing thing: #99 (the live domain-lab gMSA/SSO smoke — its ONLY remaining residual), #98 (Kerberos EPA, same DC + AD CS gate), #320 (the decisive windows-2025 sweep, blocked on an unregistered runner) and #351 (execute the failover patch against a real SQL Server, and measure ADR 0159's _acquire cost). TRIGGER IS "LAB AVAILABLE FOR VALIDATION", NOT "LAB VALIDATED" -- owner's correction to my wording, and it was circular: proving the lab does what these items need is this item's own first deliverable, so gating on validation means the trigger can never fire. Not a roadmap umbrella. Its deliverables are runs with recorded outcomes, which is the distinction that scored #64 a 1/10 for shipping nothing runnable. #351's measurement is called out as the one that is easy to lose: its patch makes the test deterministic, which removes the only thing currently raising the latency question. The item itself says a lease-election test is the wrong instrument for discovering latency -- so the measurement belongs on the rig, and landing the patch without it drops the question rather than answering it. 2. #99, #98, #320 and #351 each get a dated note that the hardware blocker HAS AN EXPIRY. #99 currently says its residual is "rig/provisioning the project does not own"; #320 says its runner is unregistered. Both are true today and scheduled to become false. Left alone those sentences keep telling every planning pass the work is unreachable -- the same stale-premise rot the 2026-07-28 reconcile found on five items and the 2026-08-03 re-score found on twenty-four. 3. §D RULE 1 amended BY OWNER RULING: the ranked table and census are not owner-only, they are recomputed by whoever writes the ledger last. Recorded as an owner decision rather than a correction, because that is exactly the standing an earlier branch of mine lacked -- it reversed the same clause on an inter-session agreement in no committed document, and was rightly refused. The argument did not change; the authority did. Recorded with the condition that makes it safe: it depends on the unit being open-bannered HEADINGS and on the re-derivation being one operation with the stale-banner sweep. Without both, sessions recomputing is worse than the stale rule. Owner-only was never safer on correctness -- it leaves the census stale whenever the owner is away, and an owner re-deriving from stale banners launders the same false-open. CENSUS RE-DERIVED under the corrected rule, and the two-directional heading<->row comparison run rather than a total-only check: 93 open headings, 93 rows, zero headings without a row, zero rows without an open heading. All four lines sum to 93. Tiers: P1 5, P2 19, P3 17, DEMAND-GATE 52 Quadrants: quick win 24, big bet 5, fill-in 55, money pit 9 The bijection check is the point, not the total: an over-count and an under-count cancel, so a total that adds up is not evidence the table and the items agree. Number allocated atomically via alloc.ps1 (#1003; #1002 was already taken). Never grepped. * docs(backlog): the fourth census line said 92 while the other three said 93 Caught in review. I moved three of the four lines, not four: "Every one of the **92 open items** is re-scored here" (:144) still read 92 while Tiers and "sum to N" read 93. Line 1 disagreed with lines 2-4 on the branch as pushed, which is precisely what the "all four lines sum to N" invariant exists to catch -- and my recompute script only rewrote the three lines it generated, never the prose sentence. Verified this tree with the repo's OWN parser rather than my regex: parse_items -> 93 items, 93 open, 0 closed. My whole-range banner scan agreed exactly. That agreement is luck, not method, and worth recording as such. parse_items ends an item's banner block at the first line that is neither blank nor a blockquote, so a `✅` sitting AFTER an item's prose is batch-filing narrative, not that item's status. My scan read the whole heading range and would have swallowed one. No item in this file currently has that shape, so the two methods agree here and would diverge on a file that does -- a reviewer's hand-rolled checker got three different wrong answers today, including calling #3, #105 and #141 closed. Use parse_items. NOT PREDICTING THE POST-#177 NUMBER. Once #177 lands (it closes #335), this tree becomes 93 items / 92 open / 93 rows, so the census wants 92. I am deliberately NOT writing 92 now: predicting it is carrying a delta in my head, which is the thing re-derive-never-delta forbids. All four lines are set to 93, which is derived and correct for THIS tree, and the number gets re-derived from the merged tree afterwards. Worth recording because it is the rule catching its own author: the two defects PARTIALLY CANCEL. Post-merge the line I missed (92) would have become accidentally correct while the 93s I did update became wrong -- and a total-only assertion sails straight through that. It is the same cancellation failure the RULE 1 amendment on this very branch warns about, arriving in the PR that carries the warning.
…banner alphabet as a contract (#180) * docs(claude): no glyphs or emoji in prose — say the word Owner-directed. Words survive grep, copy-paste, a cp1252 terminal and a screen reader; a pictograph does none of those reliably. WHY THIS IS A CORRECTNESS RULE, NOT A STYLE PREFERENCE. A glyph's meaning is POSITIONAL, and that is invisible to anyone who learns it from examples rather than from its definition. Measured 2026-08-04: the backlog's closed-marker means "this item is closed" ONLY in the leading blockquote -- quoted in an item's prose it is narrative. Two parsers of the same file disagreed on exactly that, one asking "does the glyph appear in this item" and the other "does this item declare closed status", and they AGREED ON THE CURRENT CORPUS BY LUCK because no item happens to have the discriminating shape. A word carries its scope in the sentence around it; a bare glyph does not, so it invites presence-equals-meaning reading and hides the ambiguity from review. Secondary but real: emoji need variation-selector handling in every regex that touches them, and raise UnicodeEncodeError on a stock Windows cp1252 console -- four separate failures in one session. ONE HOLDOUT, STATED AS A CONTRACT RATHER THAN AN EXEMPTION. docs/BACKLOG.md and its archive encode item status as a banner alphabet that backlog_status_check.py parses and backlog-hygiene.yml quotes: 283 banners across the two files, 12 referencing files. Changing it is a migration with its own item, not a doc edit. Until then those five glyphs stay, no NEW glyph vocabulary may be introduced anywhere, and nothing outside those two files may adopt one. And the rule that would have prevented the divergence: when reading that alphabet, import parse_items -- never re-derive it. It DEFINES item status (the banner block ends at the first line that is neither blank nor a blockquote), so a hand-rolled scan is a second, silently different definition. Same single-source discipline ledger_check.py already states for PUBLIC_BACKLOG_FLOOR. Recorded honestly: removing glyphs does NOT by itself fix the defect that prompted it. A status marker is distinguishable from a quotation only by position, and `SHIPPED:` in prose is as ambiguous as the glyph was. The parser-single-sourcing above is the fix; the glyph ban is justified on its own merits. NOTE FOR THE MERGE: CLAUDE.md is also edited by PR #177 (a new section 0). This change is in section 11 with a cross-reference in section 12, far from it, but the branch will need a rebase if #177 lands first. * docs(claude): no glyphs or emoji in prose, and sweep section 0 in the same change Owner-directed. Words survive grep, copy-paste, a cp1252 terminal and a screen reader; a pictograph does none of those reliably. Rebased onto main after #177. SWEEPING SECTION 0 HERE IS THE POINT, NOT AN EXTRA. #177 added CLAUDE.md section 0 (the not-deployed context) carrying two prose glyphs. This branch predated it, so it would have merged cleanly on distance and left the file self-contradictory: section 11 banning prose glyphs while section 0 used them, introduced by the very commit that bans them. The coordinator caught it and offered to fix it themselves; it belongs here, in the de-glyphing commit, rather than as an unexplained edit in an unrelated PR -- and this branch is docs-only at roughly 2 minutes per CI cycle against their 31. AND THE RULE CAUGHT ME WRITING IT. My own text used a warning glyph to mark the holdout paragraph. Removed. A style rule whose statement violates itself teaches the exception, not the rule. WHY THIS IS A CORRECTNESS RULE, NOT STYLE. A glyph's meaning is POSITIONAL, and that is invisible to anyone who learns it from examples rather than its definition. The backlog's closed marker means "this item is closed" ONLY in the leading blockquote; quoted in an item's prose it is narrative. Two parsers of the same file disagreed on exactly that and AGREED ON THE CURRENT CORPUS BY LUCK, because no item has the discriminating shape. A reviewer's hand-rolled checker did worse: three different wrong answers in one session, the last confidently reporting three open items as closed. The glyph looked like status wherever it appeared. Corroboration, independent of the argument: four cp1252 encode failures in one session, every one on an emoji, twice inside census checkers where the crash cost the measurement. ONE ALLOWED USE, stated explicitly because leaving it implicit would reproduce the ambiguity this rule exists to remove: quoting a glyph as a token, in backticks, to name the thing under discussion. That is code, not decoration. ONE HOLDOUT, written as a contract rather than an exemption: the backlog status-banner alphabet is machine-parsed -- 283 banners across two ledger files, 12 referencing files, and backlog-hygiene.yml quotes it in its remediation text. Changing it is a migration with its own item. Until then those five glyphs stay, no NEW glyph vocabulary anywhere, and nothing outside those two files may adopt one. Recorded honestly: banning glyphs does NOT fix the parser defect that prompted it. "SHIPPED:" in prose is as ambiguous as the glyph was. The parse_items single-sourcing guard is the fix; this stands on its own merits. Two independent changes that happened to arrive together.
…census (#183) ONE operation, deliberately. The pieces cannot be split: removing a closed-but-rowed item and adding a new rowed item both move the rank column and both change every census line, so two passes would each publish a wrong count in between. WHAT MOVED Archived #335 out. Its work landed in #177; it carried a closed banner while KEEPING its ranked-table row, which is what broke the heading-to-row bijection. Moved verbatim to BACKLOG-CLOSED.md (60 lines, unedited) and its row dropped. Filed #1002 - the AG-rig validation that PR #105 filed as #319. #319 was never allocated through alloc.ps1 and #105 gave it NO ranked-table row, so merging #105 as-is would have duplicated the item under a second number AND left a rowless heading. #1002 is allocated to this worktree. PR #105 is superseded and will be closed. Added the AOAG-DEPLOYMENT.md note #105 carried, renumbered to #1002, applied as a block rather than cherry-picked: main's copy of that file has moved 29 insertions / 23 deletions since #105 branched, so taking the file would have reverted main's newer content. The anchor slug was verified to resolve against the new heading, character for character, rather than assumed. Re-derived all four census lines plus the sum-to assertion. Note line 144 - "Every one of the **93 open items**" - was SEVEN out, not one: it is the line the recompute script does not emit, so it drifts on every filing and was last corrected in #178. Fixing the line again without fixing the generator guarantees a third occurrence; that is recorded in the plan rather than papered over here. MEASURED AFTER, with parse_items imported from backlog_status_check.py and never a hand-rolled scan (CLAUDE.md section 11 - I wrote three hand-rolled checkers in one day and got three different wrong answers, the third falsely reporting three OPEN items as closed): 101 items - 101 OPEN - 0 closed-in-file - 101 live rows open heading with no row : NONE row whose item is not open: NONE ranks contiguous 1..101 : True all four census lines sum to 101 The bijection now holds in BOTH directions. A matching total alone would not have shown that: a closed-but-rowed item and a filed-without-a-row item cancel, and every total-only assertion passes while both sets are wrong. ONE NEAR-MISS WORTH RECORDING. My first attempt renumbered 235 ranks. The live table has 101; the file also holds a superseded 134-row 2026-07-10 table, and an unscoped renumber walked straight into it and continued the sequence. Reverted and redone with the renumber bounded to the live section, plus an assertion that the historical rows are byte-identical afterwards. The gate would not have caught it - it reads item banners, not ranks. VERIFIED, including the guards a docs-only PR does not run. The pytest legs are gated on `code == 'true'`, and `.md` is in the noncode allowlist, so for this PR they are skipped pre-merge and fire only on the push to main. Run locally: test_cutover_slug_rot.py (the two-way prose ratchet, sitting exactly at its ceiling), test_backlog_status_check.py and test_feature_map_claims.py - 30 passed. Ledger gate OK at 288 items across both files. Leak gate exit 0 with real detectors armed. At least three items still carry banners that understate what shipped. Stated as "at least" on purpose: a sweep is what establishes the real count, and an enumeration here would be the completeness claim CLAUDE.md section 11 warns against.
Supersedes #171, #174 and #175, which are closed in favour of this. No content is dropped — all three commits are carried across verbatim except for one conflict resolution described below.
Why batch rather than merge three PRs
Measured on this repo today: a docs-only PR merges in ~2 minutes (the
changesjob setscode=falseand the threetestlegs skip), while a code-touching PR costs a full ~32-minute cycle. Combine that withstrict: true— every merge knocks every other open PR BEHIND, forcingupdate-branchplus a fresh full re-run — and N open code PRs cost roughly N sequential 32-minute cycles, each merge invalidating the rest.Three code PRs open simultaneously was therefore ~96 minutes of serialised draining plus cross-invalidation, for ~290 lines of change. As one PR it is one cycle. #175 had already gone DIRTY from exactly this pressure.
What is in it
558f0c07— pins the PHI-at-rest assertion form with a committed negative control, so the property cannot be quietly weakened back the next time the old assertion flakes.f2d99617— case-folds the leak gate's Windows home-path arm; all four spellings now fire,/users/still misses deliberately and is now asserted.2fdd1e05— scrubs control characters fromexc_text/stack_info, not just the rendered message, so a CR/LF-bearing traceback cannot forge a log record. ADR 0034's accepted-risk register updated in the same commit.Each was independently proven to fail: reverting reds its specific test, restoring greens it.
The one conflict, and what accepting the wrong side would have cost
docs/BACKLOG.mdconflicted on the third commit. Resolved by taking main's table wholesale and re-applying only that change's own two edits.The source branch predated #168's archive of #347, so its entire table region had diverged. Taking its side would have reverted the archive and the rank renumber behind a diff that reads clean — the divergent-branch trap. Concretely: the item's rank is 54 on main, not the 55 the commit was written against, because the archive renumbered it. Main's rank is preserved; only the note text and the banner are carried across.
Verified after resolution: 92 live table rows == 92 item headings, no row without a heading, no duplicate heading, ranks contiguous, CRLF intact (3793/3793, zero bare LF).
Known ledger staleness, stated not hidden
This flips one item's banner to DONE without re-deriving the census, so
docs/BACKLOG.mdwill assert an open-item count one higher than the truth. No gate catches that —backlog_status_check.pyis explicitly structural and says so. A later commit already scheduled to hold that file will archive the closed item, remove its row, renumber, and re-derive all four census lines from open-bannered rows.Verification
195 tests green across all three changes;
ledger_check --ciPASS;backlog_status_check --min-items 277OK at 278 items (92 open + 186 archived); ruff and the pre-commit gates pass.git cherry-pickdoes not run pre-commit, so two of these three commits passed no local gate on creation. The ledger and status gates were run by hand instead. That is a blind spot in the claim/ledger gates, not in this change.