Audit the session-drift control estate, then close the gaps it found - #40
Merged
Conversation
…ctually enforces The drift controls grew one rule at a time and were documented the same way, so no page describes them as one system. This adds that page: the four layers (prevention hooks, SessionStart detection, the commit-time claim/ledger coordination gates, recovery + lifecycle scripts), a status table, and an audit. Statuses are probe-verified -- crafted PreToolUse JSON piped into the INSTALLED hook and the emitted decision read back -- not inferred from source. That distinction is the audit's main result: the installed gate is four days older than the repo copy, nothing in the repo or CI can observe that, and rule 4 is consequently inert in production while all 85 tests pass. Every other finding is downstream of it. Also records the ultracode answer the owner asked for: rule 2 blocks dispatch only when the session's cwd IS the primary -- every worktree, nested or sibling, dispatches freely -- so the recorded "a Workflow CANNOT launch there" reading is too broad. But rule 2's premise that a subagent "cannot create a worktree for itself" is now false: `isolation: worktree` is a documented, maintained subagent surface. Recommendation is to keep the deny (it fails fast and visibly at the parent), fix the entry point rather than the deny, and retire the inert EnterWorktree rule -- the transcript relocation it guards is designed behaviour since 2.1.198 and upstream added its own prompt in 2.1.206. No behaviour change: documentation only.
… the primary as a worktree to reuse Two defects, both in the part of the gate that talks back to the model. RECEIPTS. Write-Deny wrote its decision to stdout and exited 0, leaving no trace anywhere. Nothing on this box could answer "how many drift events did we prevent last month", "is the false-positive rate 1/day or 1/1000", or "did that change do anything" -- so every severity claim about this machinery, including which drift event is the most frequent, was an opinion. Each rule now stamps its own id into a log beside the allowlist. Attribution is the point: an undifferentiated count cannot separate rule 3's true positives from its false ones. Deliberately NOT logged: the raw command. Each rule passes a $Detail it composed itself -- a verb, a target path -- so an argument carrying a token cannot reach a plaintext log, and a test asserts that. Logging is best-effort and wrapped: a deny still goes out if the append fails. DENY MESSAGE. Rule 1 lists existing worktrees so a retry reuses one instead of minting another. The filter meant to drop the primary from that list compared a path string against the PSCustomObject that Test-Governed returns, so it was always -ne and never dropped anything: the message refused a write to the primary and then named the primary as the first thing to reuse, displacing a real worktree off the 8-item cap. Compare against the canonicalised form instead. The new test needs a REAL repo -- with no `git worktree list` to read, the hint section is absent and the assertion would be vacuous. Proved it catches the regression by reverting the one-token fix and watching it go red. Also adds $GateVersion so a later parity check can report which build is installed.
…stop reading verbs out of prose Rule 3 decided two things badly, in opposite directions. BYPASSES. Rule 3 resolved its target from `-C` or cwd only; rule 3b also resolved `cd`/`pushd`. A relatively-spelled `cd ../../.. && git reset --hard` fell between them: rule 3 saw the session's own worktree and handed off to 3b, which resolved the cd, saw the primary, and returned "Rule 3 owns it" -- rule 3 having already declined. Both bowed out. And 3b only handles checkout/switch, so for the other nine verbs there was no hand-off to bow out of. `../../..` is how a session in a nested worktree names the repo root, so this was reachable by accident, and seven spellings were measured ALLOW against the shipped hook. Rule 3 also parsed `-C` with `-match`, which is case-INsensitive in PowerShell, so git's lowercase `-c name=value` was captured as the path -- and being the first match, it shadowed a real `-C` later in the same command. Rule 3b already used `-cmatch` with a comment explaining why; the rule protecting the SHARED tree never got the fix. Both now go through one Get-GitActedOnPathRaw, and Get-ComparablePath takes the session cwd as the base so a relative path resolves against the session rather than against wherever pwsh started. FALSE POSITIVES. The verb scan read the raw command, so prose supplied verbs: a second line reading "echo about to merge stuff" denied with verb=merge; `echo "git checkout main"` denied; `git commit -m "chore: clean up dead code"` denied on `clean`. Two read-only commands were denied during the audit that found this. It is not nuisance value -- rule 3's deny text is the only control on the shell-write path, so a spurious deny erodes the one guard with nothing behind it. Scanning is now per line with quoted spans blanked, sharing the newline-splitting the sibling blanket-stage hook already did. A quoted PROGRAM path keeps its git token, or the fix would trade a false positive for a false negative. Every case is a test, and each test was proved to catch its own regression: five mutations (the -cmatch, the cd branch, the line split, the quote rewrite, the resolve base) were applied to the shipped script one at a time and all five went red. One test was rewritten after mutation showed it was blind: `git -c x=y checkout main` at cwd=primary now denies EITHER WAY, because the bogus path resolves inside the primary and is governed by accident. The discriminating shape is a real `-C` the config override would shadow.
…e 4 activating as a side effect
The gate runs from a copy under ~/.claude/hooks/. Nothing compared that copy to the
repo, so drift was invisible in both directions: rule 4 sat unshipped for five days
while 85 tests reported it present, and the reverse -- a rule deleted from source but
still enforced by a stale copy -- would be equally silent. Fixing regexes is pointless
while nothing can confirm a fix reached production.
-STATUS BECOMES AN AUDIT. It now prints the version and SHA of both copies with an
explicit IN SYNC / STALE verdict, and per config dir the matchers actually wired
against the rules the INSTALLED script implements -- an expectation, not the old bare
count, which was uninterpretable without knowing what the right count was.
The CLAUDECODE refusal now sits BELOW the -Status branch. It used to precede it, so a
session could not audit the gate through the supported interface at all -- it could
neither see that the running gate was stale nor find out which rules were live.
Auditing is not installing; installing stays a human act and still throws.
RULE 4 BECOMES OPT-IN (-EnterWorktreeGate, default off). The matcher list was
unconditional, so re-running the installer to pick up any of these fixes would have
silently activated it -- and with rules 2 and 4 both live, a session started in the
primary has no in-session path to isolation at all: it can neither dispatch a subagent
nor relocate itself. That is a hard stop on workflow-by-default from the directory
sessions naturally open in, and it is the owner's call (docs/WORKTREES.md), not a
side effect of a regex fix. It also duplicates a vendor guard added in 2.1.206.
THE PARITY TEST. Local-machine, skips on CI with the reason printed so a skip is never
read as a pass, and only asserts when the source is COMMITTED -- mid-edit the copies
are supposed to differ, and a test that nagged on every keystroke would be deleted.
It is RED on this box right now, correctly: the installed gate is the Jul 24 build.
It goes green when the owner runs, from a plain terminal:
pwsh -NoProfile -File scripts\worktree\install-gate.ps1
The repo-side wiring test could never have caught this -- it compares the installer to
the script and never looks at what is installed. It now also pins what a BARE install
turns on, so an opt-in switch cannot quietly become the default.
…live until re-install Marks G1, G2, G3, G7, G10 and G12 fixed, updates the control table for the deny log, the parity check and rule 4's move from accidentally-inert to opt-in, and replaces the recommended ordering with a status. Keeps the caveat in front: the gate runs from an installed copy, so every fix on this branch is inert until `install-gate.ps1` is re-run from a plain terminal. That is the same property that left rule 4 unshipped -- the difference now is that a test watches it, and that test is red on this box until the re-install happens.
… are arranged An adversarial review of the previous commit found three regressions I had introduced, each a DENY on main that had become an ALLOW. All were the same mistake in different clothes: treating a syntactic property as if it were a semantic one. QUOTED IS NOT INERT. Blanking every quoted span stopped a commit message supplying a verb -- and also erased the verb from an interpreter argument, which is code that runs. `pwsh -NoProfile -Command "git reset --hard"` in the primary went from DENY to ALLOW, in this repo's own house idiom, and the same hole disarmed rule 3b: a session could hijack another session's worktree just by wrapping the checkout in `pwsh -Command`. Worse, the "no verb found" early exit meant the in-text path fallback was never reached, so even a command spelling the primary out in full walked through. Interpreter arguments are now recursed into rather than blanked. `ssh box "..."` deliberately is not an interpreter -- that one runs on another host, and denying it was a bug. A COMMAND IS A SEQUENCE, NOT A BAG OF TOKENS. Resolving `cd` from anywhere in the string made the rule order-blind: a cd AFTER the verb, or one already undone by popd, redirected the target away from the tree the git call actually touched. Only the text preceding the invocation counts now, and an ambiguous prefix (popd, `cd -`, a subshell, two cds) falls back to the session cwd -- the deny-side default. REDIRECTING FILES IS NOT REDIRECTING THE REPOSITORY. `--work-tree` / `GIT_WORK_TREE` say where files land; GIT_DIR still resolves from the cwd, so the shared repo's HEAD and index still move. Accepting them as "the tree the command acts on" made a one-token bypass of the whole rule. The resolver now returns a candidate SET and denies if ANY member is governed; only `-C` replaces the cwd, because only `-C` relocates both. A LINE BREAK IS NOT A STATEMENT BREAK. Folding continuations before the per-line split, so `git \` keeps its subcommand. Two things the review did not ask for but that fell out of the fix: every verb-bearing segment is now evaluated, not just the first (`git -C ../x checkout ; git checkout` judged the wrong one), and an explicit `-C` suppresses the in-text fallback, because `cd <primary> && git -C <sibling> rebase` acts on the sibling and denying it on the strength of the path appearing in the cd is a false positive. Receipts: $Detail is composed from tool input, so an embedded newline could forge extra records in the log whose only purpose is counting. Sanitised and capped, one record per line, with a pid field; and Add-Content, which silently dropped records when concurrent sessions raced, is replaced with a bounded retry. Verified by driving the db53fd4 build and this one side by side over all 26 cases -- 15 regressions, 7 false-positive fixes that must stay fixed, 4 bypasses that must stay closed. 26/26. Every case is now a test; 141 gate tests pass.
…arity test, unattributed rules, overstated docs The parity suite claimed "we print what we scanned so a skip can never be mistaken for a pass", and every print sat AFTER the skip. With no -rs in the pytest config the reasons were not shown either, so on CI the file rendered as a bare `sss.` -- the exact ambiguity it was written to remove, in the file whose whole subject is guards that cannot see. Prints now precede every skip, the skips say what was NOT compared, and the docstring states plainly which three properties CI does not guard. Rules 3b and 4 wrote receipts nothing asserted, and neither the version stamp nor the pid field was tested. Covered now, 3b against a real worktree because it asks git whether the target is one. The doc claimed rule 1 governs the primary's own .git/hooks "and a test asserts it". No test did -- the nearest one exercises rule 3. Added, for .git/hooks and .claude/hooks both: the gate's enforcement surface must not be editable through the gate. install-gate.ps1 -Status would have printed a permanent yellow "UNWIRED: EnterWorktree <- implemented but NEVER FIRES" after the required re-install, for a rule that is off by design. A guard that cries wolf every run is one a reader learns to skip, which is how the next genuinely unwired rule goes unnoticed in the same output -- the failure this audit exists to prevent. Opt-in rules now report as opt-in. Its helper comment also said it read the SOURCE while it read the INSTALLED copy; reading the installed copy is correct, so the comment was fixed to match and to say why. Doc corrections, all found by the review: the status table now states per item what is actually done versus outstanding (B1 emits no liveness receipt; B3 leaves GIT_DIR= unhandled; B4 still does not share a split helper with block-blanket-git-stage.ps1, so the two hooks can still disagree about what a command is) rather than listing five items as complete. Line and test counts corrected. An overstated status is worse than none -- the next session acts on it.
wshallwshall
enabled auto-merge (squash)
July 29, 2026 15:17
…onfig that disarms every worktree Two holes with one shape: the thing doing the enforcing was not itself protected. RULE 1a -- the gate's own surface. The installed hook and its allowlist live OUTSIDE every governed root, so Test-Governed returned null for them and rule 1 allowed an Edit to either. One line written to the allowlist disarms the gate for every session on the machine, permanently and silently. The standing answer was that the kill switch is "deliberately NOT named in the deny message" -- obscurity over a file one `ls` away, and not a control. Scoped to the two exact FILES, not their directory. Keying on the parent looked tidier and was wrong twice: the allowlist path is a parameter that can point anywhere (under test it lands in a temp dir, where a directory rule swallowed every unrelated path and failed seven tests), and ~/.claude/hooks/ holds unrelated things this rule has no business governing. settings.json is deliberately NOT covered. The update-config skill exists to edit it, and blocking it would break a supported workflow to close a hole that needs a far more deliberate act than deleting a stray-looking text file. The installer is unaffected either way: it writes from a plain terminal via Copy-Item, which is a shell call and not an Edit. That asymmetry is the point -- the human installs and removes the gate. RULE 3c -- git config that disarms the shared repository. `config` changes no tree, so the verb list never saw it, and its blast radius is larger than any tree swap: all eight worktrees share one .git, so `git config core.hooksPath /dev/null` run in ANY of them disables the ledger, claim and secret-leak commit gates for EVERY one at once, and nothing reports that they stopped running. `-c core.hooksPath=` does it for a single command, which is enough for one commit to slip past. Deliberately does NOT reuse Test-Governed: its .claude/worktrees/ exemption is exactly wrong here. A linked worktree is not the primary -- correct for a tree swap, and the opposite of correct for a config write that lands in the shared file. 3c asks git for the common dir instead, which covers nested worktrees, sibling worktrees and the primary alike, and falls through to ALLOW on any git failure. Narrowness is the feature, and half the new tests exist to hold it: reads (--get, --list, --show-origin), every key off the disarm list, an ungoverned repo, and a non-repo cwd all stay untouched. A guard that also blocks ordinary work gets routed around. Four mutations applied to the shipped script one at a time -- 1a never firing, 3c never detecting a key, 3c losing its governed-root match, 3c losing its read exclusion -- all four went red. 166 gate tests pass.
…er, and deliver the leak-gate token to first-party worktrees Three findings from the drift audit, all in the half of the estate that gets forgotten because the PreToolUse gate is the loud one. ONE ALLOWLIST. The gate read ~/.claude/hooks/worktree-gate.repos.txt; the SessionStart backstop read ~/.claude-hooks/worktree-gate.repos.txt. install-gate.ps1 rewrote its own unconditionally, install-selfheal.ps1 seeded the other only if absent, and nothing kept them in sync: adding a governed repo through one installer never reached the other, and `install-gate.ps1 -Uninstall` left the backstop armed and still willing to run `git checkout` on the primary long after the gate was gone. They agreed by luck. The backstop now reads the gate's file, with the legacy path as a FALLBACK -- it is installed as a copy, so a version skew between copy and allowlist must not silently turn it off. THE HIGHER-PRIVILEGE INSTALLER WAS THE UNPROTECTED ONE. install-gate.ps1 has refused to run under CLAUDECODE since it shipped. install-selfheal.ps1 did not, while wiring a user-scope hook that runs `git checkout` on the shared primary unattended, from a script whose canonical source is the calling session's own worktree. It refuses now, before writing anything, and a test pins the pair so the asymmetry cannot reappear. .WORKTREEINCLUDE. `git worktree add` delivers tracked files only, and the leak gate's token list is deliberately gitignored while the pre-commit hook passes --require-tokens and fails closed. new.ps1 hand-copies it for the worktrees IT makes -- which covers none of the first-party paths (`claude --worktree`, desktop parallel sessions, `isolation: worktree` subagents), and those are where every nested .claude/worktrees/ session lands. Such a worktree could not commit AT ALL, with an error that never mentions worktrees. Deliberately excludes .env and secrets/ (refused by policy) and .venv (per worktree on purpose, or a shared editable install silently tests the wrong checkout). The first draft of the allowlist tests asserted only "exit code 0", which the hook returns whether or not it read anything -- vacuous, and the exact pattern this audit exists to catch. They now build a clean repo parked on the wrong branch and assert the REPAIR happens, with an ungoverned-repo control proving the assertion can fail.
…s on does not hold
Rule 2 denies Task/Agent/Workflow dispatch from the primary, and its third and decisive
justification was that "a subagent's denied edits do not reliably surface back to you,
so the fan-out would appear to succeed while writing nothing". That was one
undocumented observation, never re-measured, and it is the half that justifies a DENY
rather than a warning.
Tested directly: a subagent dispatched from this worktree, instructed to make exactly
one Write into the primary.
cwd inherited YES -- that part of the premise holds
write landed NO -- rule 1 denied it; ls confirms the file never existed
denial surfaced LOUDLY -- full deny text received and reported verbatim; the
subagent did not silently report success
parent can detect YES, NOW -- the deny left a receipt stamped with the subagent's
own pid
Two consequences. Rule 1 already CONTAINS a fan-out from the primary, because it keys on
the target path regardless of where the parent sits. And the receipt added earlier in
this branch closes the observability gap rule 2 was built to work around -- an empty
permission_denials list is exactly what a timestamped, pid-stamped log replaces.
So rule 2's remaining value is failing FAST, at the parent, before a long fan-out rather
than after. Real, but much narrower than its deny text claims, and a reason to revisit
deny-versus-warn rather than to keep it at deny by default. Recorded rather than acted
on: changing it is the owner's call.
Also confirmed live in the same probe: the primary is no longer offered in the deny
message's "worktrees you could reuse" list -- G7's fix, in production. Still open: that
list names other sessions' worktrees, which the gate explicitly permits writing into.
…tree Every rule so far protects a working tree from being SWAPPED. None protected it from being DELETED, which is strictly worse: `git worktree remove` takes the directory and its branch along with any uncommitted work in them, there is no undo, and the session using it finds out when its next file read fails. The verb list could never have caught this. Every entry in it is a single token; this is two (`worktree remove`), and `worktree` on its own is a read used constantly. Note also that git refuses to remove the worktree you are STANDING in -- so a `worktree remove` that reaches git is, by construction, aimed at somebody else's. The target is the PATH ARGUMENT, not the cwd, and it cannot be judged with Test-Governed: a linked worktree is exempt there (correctly, for tree swaps) and a sibling worktree falls outside the roots entirely. Rule 3d asks git whether the path is a registered worktree of a governed repo instead, which covers both layouts. Any git failure -- a path that is not a worktree, or does not exist -- falls through to ALLOW. Narrow, and the tests hold it there: `worktree list` stays allowed (the deny message recommends it as the way to check whether a worktree is in use, so blocking it would make the message a trap), `worktree add` stays allowed (it is the sanctioned path out of every other deny in the file), and an ungoverned repo's worktrees are untouched. Two mutations -- 3d never detecting the subcommand, 3d losing its governed-root match -- both went red. 183 gate tests pass.
…efault that preempts the guard behind it
Linux CI caught this, and it is a better bug than the one the test was written for.
$env:USERPROFILE exists only on Windows. Everywhere else it is NULL, and
`Join-Path $null ...` raises "Cannot bind argument to parameter 'Path' because it is
null" rather than returning a path. Four scripts dereferenced it unguarded.
The interesting part is WHERE. In install-selfheal.ps1 it was a PARAMETER DEFAULT, and
defaults are evaluated during BINDING -- before the first line of the body. So the
CLAUDECODE refusal I added one commit ago was unreachable on Linux: the script died with
an unrelated null-path error and never refused. The test asserting the refusal failed,
correctly, for a reason nobody predicted. A guard is only a guard if nothing can run
ahead of it, so that default moved into the body, after the guard.
The same shape in worktree_gate.ps1 would be worse and quieter: its $ReposFile default
is also evaluated at binding, and a hook that exits non-zero-but-not-2 lets the tool call
through SILENTLY. Off Windows the gate would simply be off, with nothing to say so.
All four now resolve the home directory the same way -- honour $env:USERPROFILE when set,
because tests and account swaps override it, and fall back to
[Environment]::GetFolderPath('UserProfile'), which resolves $HOME on Unix.
Three tests, and getting to them took two rounds of mutation.
Round 1: unset USERPROFILE and assert the absence of the null-path bind across all four
scripts. Killed the regression in worktree-selfheal.ps1 and install-gate.ps1; SURVIVED in
install-selfheal.ps1, because the CLAUDECODE guard now exits before $homeDir is computed.
Correct behaviour, and it means that test structurally cannot see a regression there.
Round 2: added an isolated completion test (-HookPath and -ConfigDir both in tmp).
Still survived -- passing -HookPath means $homeDir is never dereferenced at all.
The only path that touches it is a plain-terminal run with no -HookPath, which is exactly
how an operator invokes it, and which writes under the resolved home. That is safe to test
only where the home can be redirected: on Unix the fallback resolves $HOME. So the third
test is Unix-only and SKIPPED on Windows with the reason stated, rather than faked into
something that passes everywhere and proves nothing. Linux CI is where the bug appeared
and is where that assertion runs.
191 gate tests pass, 2 skipped.
wshallwshall
added a commit
that referenced
this pull request
Aug 4, 2026
…ones (#163) * docs(backlog): close BACKLOG #226 — the estate Hybrid-layout sweep is done, off-repo The per-feed Hybrid split (connections.toml / <INBOUND>_router.py / <INBOUND>_handler.py / _<feed>_transforms.py) landed across the ported estate in the maintainer-internal migration repository. Owner-attested; nothing in this repository changes, which is also why leaving the item open could never have closed it. Both "Also" clauses are recorded as NOT delivered, with the reason each is not a residual of this item: - "align the IDE Corepoint-import / scaffold path to emit the Hybrid layout" — there is no Corepoint-import path in ide/ to align. That tooling is #105, still open, so the clause is a constraint on #105's design rather than work #226 can perform. The scaffold half is misaddressed too: Insert Element (#48) drops per-file idioms into the current buffer (ide/src/insertElement.ts:1-5) and emits no multi-file feed layout. - "consider a recursive-glob / folder-per-feed loader enhancement" — filed as a consider, and not taken: load_config still globs *.py non-recursively (config/wiring.py:4162), the flat-merge behaviour the Hybrid layout is built around. Follows the #227 precedent: close the primary, state the off-repo/misaddressed residuals explicitly so the item is not re-opened for them. backlog_status_check.py: OK — 277 items, each declaring exactly one status. * fix(ledger): teach the number-space gates to span an archive, and fix two holes found proving it Prerequisite for moving the 185 closed BACKLOG items into docs/archive/backlog/. No item has moved yet; this only makes the guards able to see one when it does. The item namespace will span two paths, so every guard now reads their UNION: - backlog_status_check.py: scan() takes (label, text) pairs and parses them as ONE namespace. A number re-used across BACKLOG.md and the archive was structurally undetectable before -- `seen` was per-parse -- which is the erratum's own shape. - ledger_check.py: triggers on any backlog-bearing path, not the one literal, and builds head/base as the union. Reading the union on both sides also removes a false positive: the move relocates 185 items, so head-union == base-union and `head - base` stays empty, where a per-file view would report 185 vanished numbers with a remedy that renumbers cited items. - alloc.ps1: sweeps both paths in the all-refs term and the working-tree term. - backlog-hygiene.yml: accepts a banner updated in either location. Two pre-existing defects surfaced only because the gates were made to fail on purpose first, neither of which is about the archive: 1. alloc.ps1's working-tree term has NEVER worked. `[regex]'^...'` anchors at the start of the STRING; the term feeds it `Get-Content -Raw`, one string starting "# Backlog". Measured: 0 of 277 headings matched without Multiline, 277 with. The all-refs term hid it by covering every number committed somewhere -- i.e. every case except the uncommitted one this term exists for. 2. backlog-hygiene.yml diffed BASE_SHA..HEAD_SHA (two-dot), which credits a PR for main-side changes to paths it never touched. One main-side edit to BACKLOG.md -- the move being a large one -- would let every PR with an older base pass the "must update BACKLOG.md" required check while enforcing nothing. Now three-dot, matching ci.yml's form for the same question. Anti-narrowing, because a green gate over a shrunken corpus is the failure mode: - `--min-items N` fails when fewer items are found than required, and CI pins 277. Without it, 277 -> 92 fails nothing. - The scanned files are always printed with the count; a bare integer cannot distinguish "items closed" from "a file stopped being read". - A liveness receipt in the test suite asserts the same floor. - An explicitly-named --backlog path that does not exist is an error, not a skip. alloc.ps1 gains `-ShowFloor`: print the floor and the swept paths, allocate nothing. Allocation is a one-way door, so before this the only way to ask what the floor could see was to spend a number on the question -- which is how it ran a whole release reading two refs while its header promised all of them. Get-Floor takes -Peek so the inspection cannot advance the high-water ratchet; the first -ShowFloor run against a planted number moved this clone's watermark 316 -> 990 before that was fixed. Proofs run, each observed failing BEFORE the fix: - archive-only unallocated #1007 staged: old gate rc=0, new gate BLOCKED. - #990 planted in the archive: old sweep floor 353 (blind), new sweep 990. - cross-file duplicate #118: detected, naming the other file. - banner violations inside the archive only: detected. - --min-items over a narrowed corpus: rc=1 with the scanned-file list. - -ShowFloor twice against a plant: watermark unchanged at 316. ruff + mypy --strict clean; 43 gate tests pass. * docs(backlog): move the 185 closed items into docs/archive/backlog/BACKLOG-CLOSED.md docs/BACKLOG.md becomes the ~92 items someone can act on: 8,742 -> 3,648 lines. The closed items are not deleted, summarised, or rewritten -- they are relocated verbatim, so the file that gets opened, grepped and edited daily is the open set. MOVED, NOT REWRITTEN. Every relocated block is byte-identical to the one that left BACKLOG.md, headings included. Verified mechanically against a pre-move copy: - 277 items before = 92 after + 185 archived, no overlap, union identical - every OPEN block byte-identical to its source - every ARCHIVED block byte-identical to its source - all non-item prose in BACKLOG.md preserved verbatim Byte-identical headings are load-bearing, not tidiness: GitHub derives anchor slugs from heading text, so all 64 archived->archived cross-references keep resolving with no edit at all. That is the whole argument for one archive file rather than a split by status, year, or cluster -- #52 alone receives 99 of the 110 in-file anchors, and its citers span #65 to #184, so no cut isolates them. Cutting item blocks at the next '## ' heading of EITHER kind, not the next numbered item: 4 blocks in this file are followed by a section header, which a naive cut would have dragged into the archive along with the prose beneath it. Anchors, all 127 re-resolved against real headings after the edit: - 44 rewritten in BACKLOG.md -> archive/backlog/BACKLOG-CLOSED.md#<same-slug> - 1 rewritten in the archive -> ../../BACKLOG.md#<same-slug> (#226 -> #105) - 3 cross-file links repointed: AOAG-DEPLOYMENT.md (#100, #101), ADR 0026 (#30) - 64 archived->archived untouched, by design 13 anchors still do not resolve, and ALL 13 WERE ALREADY DEAD BEFORE THIS COMMIT -- confirmed by running the same check over the pre-move file, which returns the identical multiset (11 bare-number self-anchors: #40 x4, #323 x3, #28, #29, #329, #333; plus 2 links to #13 in COUNSEL-ENGAGEMENT-BRIEF.md, a number this sequence never had). They are left dead and documented in the archive header rather than repointed at a plausible neighbour: a citation resolving to the WRONG item is the erratum's failure mode, and unlike a dead link it looks like success. The archive carries its retirement banner inline rather than in a sibling README -- docs/archive/throughput/ needs a README because it indexes five documents; one file does not, and two documents that must agree is a drift surface. It states the rules that keep the namespace honest: never renumber, re-open by moving the block back (never by copying, which creates the cross-file duplicate the status check now fails), and add any future archive file to alloc.ps1's $backlogPaths AND backlog_status_check.py's DEFAULT_SOURCES in the same commit -- a file named in neither is policed by nothing. Gates verified post-move: - backlog_status_check.py --min-items 277: OK, 277 items, and it now PRINTS "scanned: docs/BACKLOG.md (92), docs/archive/backlog/BACKLOG-CLOSED.md (185)" - ledger_check.py on the staged move: rc=0 (relocation adds no numbers, because head-union == base-union -- the exact false positive the union view removes) - alloc.ps1 -ShowFloor: floor 353 across both paths, next 1000 - 43 gate tests pass Note the floor is unchanged at 353 because the highest item (#353) is open and stays in BACKLOG.md. The archive-sweep fix is therefore PROSPECTIVE, not a save: it starts mattering the first time a top-of-range item closes and moves. * docs(backlog): re-score all 92 open items on the ten-level scale (2026-08-03) Every open item now carries a current value x difficulty score. Before this, 23 had none at all and the other 69 were from the frozen 2026-07-10 pass, which predates the 2026-07-28 reconcile that closed 31 items -- and a stale score reads exactly like a fresh one. Method, unchanged from the pass it supersedes: scored from each item's own Scope / Why / Trigger / Nearest-existing-mechanism text rather than rescaled from the old number, then adversarially verified against the code -- a second reader per batch attacking build state first, then verdict/tier, then value and difficulty. 26 of 92 scores were overturned by that pass and carry the refuter's number. The banner is the live record and the table is a view of it; both are written here and a mechanical check confirms 92 banners and 92 rows agree on every triple. THE RATIONALE IS REPLACED, NOT JUST THE NUMBERS. Carrying an old justification under a new score is how a banner comes to argue against itself: - #114's surviving "clean workaround via the on-demand test probe" is a claim PR #162 explicitly retracted -- both destinations' test_connection CREATE the target dir, so the probe cannot answer the question the toggle asks. That is what lifts it off the parity-with-a-workaround band to 6/3. Its replacement rationale was ALSO stale (it described the silent-ignore #162 had just fixed) and is hand-corrected. - #105's "large greenfield 71-action mapper needing its own ADR" describes an importer that has since shipped under ADR 0086. Scheduling barely moved, which is the reassuring result: only TWO tiers changed -- #64 DEMAND-GATE -> P3 (an index over levers that live in #62/#63/#47/#34, so it ships nothing runnable of its own) and #105 P3 -> DEMAND-GATE. Neither contradicts an explicit demand-gate/on-trigger ruling in its own body; that was checked for all 51 items carrying a prior tier. Distribution is RECOMPUTED with the table rather than carried forward, and all four lines sum to 92. The superseded table keeps its own frozen lines and now says so. Tiers: P1 4, P2 19, P3 17, DEMAND-GATE 52 Quadrants: quick win 22, big bet 5, fill-in 56, money pit 9 The four P1s: #341 (9/3, a handler returning a tuple/set of Sends delivers nothing silently -- an accept-and-drop CLAUDE.md §12 forbids), #324 (7/2), #325 (6/2), #327 (6/2). NOT in this commit: 24 items were found to misdescribe their own build state -- prose asserting a gap that has since shipped, or citing messagefoundry/console/, a package retired with #103. Those are banner corrections and land separately; the scores here already price the remainder rather than the original scope. Two mechanical faults were caught by reading the output rather than trusting the run: the quadrant regex omitted the hyphen in "fill-in", so 57 of 69 items took the fallback branch and got a SECOND score inserted beside the first; and the synthesizer's own distribution lines did not follow from its own table (11 quadrant mismatches, 8 ordering violations, difficulty summing to 95 of 92). The script now refuses to write when any line carries two score spans or the scored count is not 92. backlog_status_check.py --min-items 277: OK, 277 items across both files. * docs(backlog): correct 10 items whose own prose misdescribed build state The 2026-08-03 re-score flagged 24 open items as misdescribing what the code does. Re-verified each against the tree as it stands -- after the archive move and after PR #162, both of which post-date the findings -- and 10 survived. The other 14 did not, and are recorded here rather than silently dropped: #84 #95 #99 #105 #114 #124 #125 #127 #133 #137 #167 #169 #214 #228 Most of those already carry an amendment that covers the stale sentence (#95, #99, #105, #114, #124, #125, #127, #133, #228), and stacking a second ruling saying the same thing is noise. The rest did not survive verification: the finding was itself wrong or overstated, and a wrong correction in a ledger is worse than a stale one. CORRECTIONS ARE ADDED AS DATED AMENDMENTS, NOT PROSE REWRITES. This file's convention is to leave the original claim standing and rule against it, so the record shows what was believed and what replaced it. Silently editing the stale sentence would destroy the evidence that makes the correction checkable. Applied to #62 #64 #131 #166 #179 #182 #237 #321 #329 #336. Representative: - #329 "Five MEFOR_ALLOW_INSECURE_TLS cells": the census is FOUR. #323 landed and routed transports/direct.py through the clamp; it now holds no call to the raw predicate at all (:63, :197, :215). - #321 "no test asserts the detectors can see a site code": false -- tests/test_scan_forbidden.py has per-class hit tests for at least the site code (:126), a customer name (:83), a case-sensitive code (:91) and a routable IP (:107). The detector-coverage half of its Proposed 2 is already in the tree. - #62 plans a dual-read over "existing mfenc:v1 rows", but cell-bound mfenc:v2 is the default writer (settings.py:383 -> base.py:1841; crypto.py:36), and v2 folds (table, column, pk) into the GCM tag -- so a body landing under a different column must be RE-ENCRYPTED, not merely re-encoded. That tightens the catch. - #64's ordered plan still reads live ("Nothing builds before it"), but the measure-first phase completed 2026-07-12 (ADR 0051) and its step-2 lever is refused outright (ADR 0055 withdrawn; ADR 0107 "Do not build F2 or F3"). The refuters removed two overclaims before they landed: #62's draft asserted a live store holds both mfenc markers (a fresh store under the shipped default holds only v2 -- the defensible claim is that a MIGRATION must expect both), and #64's asserted the multi-DB log split still remains, which could not be verified against ADR 0098 and would have been a fresh false claim. No item closes here: in every case the correction narrows the remainder rather than discharging it, and the 2026-08-03 scores already price the remainder. backlog_status_check.py --min-items 277: OK, 277 items, one status banner each. * docs(backlog): file BACKLOG #1000 — prove each required merge context can fail Escalated by the coordinator on the ground that it outlives the PR that fixed it. Deliberately NOT filed as "fix the two-dot diff": that instance already landed in 39b62bf, and filing shipped work is the rot the hygiene gate exists to prevent. The item is the CLASS. `.github/required-contexts.txt` names 13 contexts that block merge, and not one of them is proven able to go red. The deliverable is a negative control per context -- a fixture carrying the exact violation that context exists to catch -- plus a CI job that fails when a required context has none, so the coverage cannot silently decay as contexts are added. Scoped narrower than "test the gates" on purpose: it does not re-test what each gate checks, since the gates' own suites do that. It asserts one property per context -- this gate is capable of failing. The argument is that the class has now fired at least four times here, each found by hand and none by CI: #334 semgrep, required and blocking, scans a two-directory allow-list #327 six .gitignore rules are the sole control over maintainer-internal docs, and nothing asserts they still match anything #321 the forbidden-content gate exited 0 on a real site code and partner product #325 the same gate's home-path detector misses 1 of 4 spellings of a Windows path Each is correctly filed as its own defect. None of them establishes the property that would have caught all four before they shipped, and that property is a different artifact from any of the individual fixes. Value 7 / Difficulty 3, quick win, P1 -- not demand-gated; the trigger fired four times. Ranked table and all four distribution lines recomputed to 93 open items; a mechanical check confirms 93 banners and 93 rows agree on every triple. Number allocated atomically via scripts/coord/alloc.ps1 (#1000 -- the first in the post-partition public sequence, clamped to >= PUBLIC_BACKLOG_FLOOR), never grepped. backlog_status_check.py --min-items 277: OK, 278 items across both files. The floor is a floor, so growth passes it; it is there to catch shrinkage.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Documents the session-drift machinery as one system, audits what it actually enforces, and fixes what the audit found.
Why
The controls grew one rule at a time and were documented the same way, so no page described them together. Auditing them turned up a root cause that made every other finding possible: nothing could observe what was actually installed.
The gate executes from a copy under
~/.claude/hooks/.install-gate.ps1copied it with no version, hash or marker, and-Statusprinted an uncalibrated count of hook entries. So rule 4 sat unshipped for five days while 85 tests reported it present — and the reverse (a rule deleted from source but still enforced by a stale copy) would have been just as silent.What is here
docs/SESSION-DRIFT-CONTROLS.md— the estate as one system: prevention hooks,SessionStartdetection, the commit-time claim/ledger gates, recovery and lifecycle scripts. Every status is probe-verified, by piping craftedPreToolUseJSON into the installed hook and reading the emitted decision, not inferred from source.Then the fixes, one coherent layer per commit:
Write-Denywrote its decision to stdout and exited 0, leaving no trace. Nothing could answer "how many drift events did we prevent", so every severity claim about this machinery was an opinion. Each rule now stamps its own id. The raw command is deliberately not logged — each rule passes a detail it composed itself, so an argument carrying a token cannot reach a plaintext log.PSCustomObject, so it was always-neand never filtered: the message refused a write to the primary and then offered the primary as the first thing to reuse.-Cread case-sensitively (git's lowercase-cwas being captured as a path, and shadowed a real-C);cd/pushdhonoured only from the text preceding the invocation;--work-tree/GIT_WORK_TREE/--git-diras additional candidates rather than exemptions; per-line scanning with quoted spans blanked and interpreter arguments recursed into; continuations folded.-Statusreports version/SHA parity with an explicit IN SYNC / STALE verdict and per-dir matchers against an expectation. TheCLAUDECODErefusal moved below the-Statusbranch — a session could not previously audit the gate through the supported interface at all. Installing still throws.-EnterWorktreeGate, default off) rather than unconditional. Re-installing to pick up any of these fixes would otherwise have activated it silently, and with rules 2 and 4 both live a session started in the primary has no in-session path to isolation at all. That is the owner's call, not a side effect.How it was verified
Each fix was proved to catch its own regression by mutation — five mutations applied to the shipped script one at a time, all five went red.
An adversarial review of the first attempt then found three regressions it had introduced, each a DENY on
mainthat had become an ALLOW: a git verb inside an interpreter argument (pwsh -Command "git reset --hard") hidden by quote-blanking, which also disarmed rule 3b; an order-blindcdresolver; and--work-treeaccepted as an exemption whenGIT_DIRstill resolves from the cwd. All fixed and pinned bytests/test_worktree_gate_shell_semantics.py. Verified by driving thedb53fd45build and this one side by side over 26 cases — 15 regressions, 7 false-positive fixes that must stay fixed, 4 bypasses that must stay closed. 26/26.Two of the new guards were themselves blind and were caught the same way: a test of the
-cfix that passed against the bug, and a parity suite whose "we print what we scanned" claim sat after its skips, rendering as a baresss.on CI.Live after install:
parity: IN SYNC, 13 end-to-end probes against the real allowlist behave as designed, receipts writing.Full suite: 9252 passed, 797 skipped, 0 failed.
Not done, stated plainly
GIT_DIR=is unhandled. The parity check emits no liveness receipt, so on CI it is three honest skips. The split helper is still not shared withblock-blanket-git-stage.ps1, so the two hooks can still disagree about what a command is. The doc's status table says so per item — an overstated status is worse than none, because the next session acts on it.🤖 Generated with Claude Code