Skip to content

Audit the session-drift control estate, then close the gaps it found - #40

Merged
wshallwshall merged 12 commits into
mainfrom
claude/session-drift-prevention-0979f7
Jul 29, 2026
Merged

Audit the session-drift control estate, then close the gaps it found#40
wshallwshall merged 12 commits into
mainfrom
claude/session-drift-prevention-0979f7

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

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.ps1 copied it with no version, hash or marker, and -Status printed 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, SessionStart detection, the commit-time claim/ledger gates, recovery and lifecycle scripts. Every status is probe-verified, by piping crafted PreToolUse JSON into the installed hook and reading the emitted decision, not inferred from source.

Then the fixes, one coherent layer per commit:

  • Receipts. Write-Deny wrote 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.
  • Deny message. Rule 1's "worktrees you could reuse" filter compared a path string to a PSCustomObject, so it was always -ne and never filtered: the message refused a write to the primary and then offered the primary as the first thing to reuse.
  • Command parsing. One shared resolver for the tree a git command acts on; -C read case-sensitively (git's lowercase -c was being captured as a path, and shadowed a real -C); cd/pushd honoured only from the text preceding the invocation; --work-tree/GIT_WORK_TREE/--git-dir as additional candidates rather than exemptions; per-line scanning with quoted spans blanked and interpreter arguments recursed into; continuations folded.
  • Observability. -Status reports version/SHA parity with an explicit IN SYNC / STALE verdict and per-dir matchers against an expectation. The CLAUDECODE refusal moved below the -Status branch — a session could not previously audit the gate through the supported interface at all. Installing still throws.
  • Rule 4 is now opt-in (-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 main that 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-blind cd resolver; and --work-tree accepted as an exemption when GIT_DIR still resolves from the cwd. All fixed and pinned by tests/test_worktree_gate_shell_semantics.py. Verified by driving the db53fd45 build 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 -c fix that passed against the bug, and a parity suite whose "we print what we scanned" claim sat after its skips, rendering as a bare sss. 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 with block-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

…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
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
wshallwshall merged commit 98c9cc9 into main Jul 29, 2026
33 checks passed
@wshallwshall
wshallwshall deleted the claude/session-drift-prevention-0979f7 branch July 29, 2026 16:42
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant