Skip to content

ADR 0156 — ASVS scorecard as data: derived count, verified evidence anchors, fail-closed drift gate - #120

Merged
wshallwshall merged 10 commits into
mainfrom
claude/adr-asvs-scorecard-as-data
Aug 1, 2026
Merged

ADR 0156 — ASVS scorecard as data: derived count, verified evidence anchors, fail-closed drift gate#120
wshallwshall merged 10 commits into
mainfrom
claude/adr-asvs-scorecard-as-data

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

The ASVS score is maintained as prose and nothing checks it. One re-anchoring session on 2026-08-01 produced, in a single day:

Observed Count
Re-derivations of the headline count from scratch 6
Residuals of record factually false at HEAD 12
Of those, absence claims that had silently stopped being true 5
Cells missing from an enumeration called "arithmetic-checked and complete" 10
Documents asserting a superseded count as current 5
Subagent tokens spent verifying ~23 million

Most of that wasn't discovery. It was re-deriving facts already known but never durably recorded, then re-verifying them because the recorded version had gone quietly false.

The instructive one is the dropped ten: it survived because the arithmetic closed to 345 and closure was read as proof. Closure only proves the four buckets sum — not that every cell landed in one. No self-consistent count can detect an absent cell.

What this adds

scripts/asvs/scorecard.py — stdlib only (tomllib/json/re), no new dependency, and deliberately data-free: it takes the scorecard and the ASVS corpus as paths, so this repo unit-tests it against fixtures while the vault runs the same code against real posture data.

Check The defect it kills
Every corpus id appears exactly once, nothing outside it appears the 10 dropped cells
Count computed, never typed 5 documents asserting 3 counts
Each cell's evidence names a token that must still resolve the 12 false residuals
An absence claim needs a positive control that must still match the 5 false absence claims
unverified is a first-class verdict ~219 inherited Passes hiding inside a headline
Missing scorecard → exit 2, never 0 ASVS 15.1.3

On absence specifically: a grep naming the wrong token returns zero and reads exactly like proof. That's how five residuals stayed false for weeks. So the schema stores the search, not just its conclusion — and if the positive control goes quiet, the claim is void regardless of what the pattern returns.

Tests

17 tests, and every check is proved to go RED before it is trusted green — dropped cell, duplicate cell, an id ASVS retired in 5.0, a level disagreeing with the corpus, a moved token, a deleted file, a blind absence search, an absence that became false.

Exit codes verified by hand: 0 clean · 1 findings · 2 could-not-measure.

ruff check / ruff format --check / mypy all clean.

§7 was amended at ratification — the premise was false

The ADR originally proposed "a vault CI job runs the verifier against the real data." Open question 1 asked whether that CI actually executes. It does not — every vault workflow is disabled_manually, last run 2026-07-27, and both vault PRs that day merged with zero checks.

A CI-only design would have shipped dead. Built instead as a vault pre-commit hook plus one narrow new workflow (new workflows are active by default even though the estate is off).

That §7 was a confident, plausible, unchecked claim about system state is this ADR's own thesis applied to itself. It was caught only because ratification was gated on answering the open question rather than on the argument reading well.

What this does not do

It does not make the score correct — only consistent, derived and drift-detecting. A wrong verdict recorded carefully is still wrong; adversarial verification remains the only cure for that, and this ADR doesn't replace it.

The companion vault PR carries the real scorecard and the enforcement.


🤖 Generated with Claude Code

…sting 23M tokens

The ASVS score is maintained as prose and nothing checks it. One re-anchoring
session on 2026-08-01 produced, in a single day:

  6   re-derivations of the headline count from scratch
  12  residuals of record factually FALSE at HEAD
  5   of those were ABSENCE claims that had silently stopped being true
  10  cells missing from an enumeration called "arithmetic-checked and complete"
  5   documents asserting a superseded count as current
  4   register-vs-scorecard contradictions
  ~23M subagent tokens spent verifying

Most of that was not discovery. It was re-deriving facts already known but never
durably recorded, then re-verifying them because the recorded version had gone
quietly false.

The dropped-ten defect is the instructive one: it survived because the arithmetic
closed to 345 and closure was read as proof. Closure only proves the four buckets
sum -- not that every cell landed in one. No self-consistent count can detect an
absent cell.

Decision: one [[cell]] record per requirement for all 345; the count is COMPUTED
so no document can state one; a test asserts every corpus id appears exactly once;
each cell's evidence anchor names a TOKEN that CI asserts still resolves, so code
movement reds a test instead of rotting a sentence; an absence claim must record
the search AND a positive control that must still hit; `unverified` is a
first-class verdict so inherited-vs-verified Pass is countable; and the verifier
fails closed rather than skipping.

Tool and schema here (public CI, fixture data); real scorecard in the vault with a
vault-CI job. That placement closes ASVS 15.1.3 as a by-product -- six *_doc_drift
modules currently assert against documents that `git ls-files docs/security/`
shows are absent from the tree where CI runs, so they are inspection-only theatre.

States its costs plainly, including that it does NOT make the score correct --
only consistent, derived and drift-detecting.

Number allocated atomically via scripts/coord/alloc.ps1; index row in this commit.
…il-closed (ADR 0156)

The tool half of ADR 0156. Stdlib only (tomllib/json/re), no new dependency, and
deliberately data-free: it takes the scorecard and the ASVS corpus as paths, so
this repo unit-tests it against fixtures while the vault runs the same code
against the real posture data.

What it enforces, each aimed at a defect observed on 2026-08-01:

  check_completeness -- every corpus id appears EXACTLY ONCE and nothing outside
  the corpus appears at all. This is the check whose absence cost ten cells: an
  enumeration closed to 345 and closure was read as proof. Closure only proves
  the four buckets sum, not that every cell landed in one.

  count() -- computed, never typed. Five documents asserting three counts is what
  this replaces.

  check_anchors -- each cell's evidence names a TOKEN that must still resolve
  within a window of the recorded line. Code movement reds a test instead of
  rotting a sentence. That is the twelve-false-residuals defect.

  check_absences -- an absence claim is admissible ONLY with a positive control
  that must still match. A grep naming the wrong token returns zero and reads
  exactly like proof; five residuals of record died that way. If the control goes
  quiet the search is BLIND and the claim is void regardless of the pattern.

  unverified is a first-class verdict, so an inherited Pass cannot hide inside a
  headline. The renderer reports verified-vs-inherited separately.

  Fail closed: a missing scorecard EXITS 2 (could not measure), never 0. That
  distinction is the whole of ASVS 15.1.3, where six *_doc_drift modules skip
  because their target is not in the tree where CI runs.

17 tests, and every check is proved to go RED before it is trusted green --
dropped cell, duplicate cell, an id ASVS retired, a level disagreeing with the
corpus, a moved token, a deleted file, a blind absence search, an absence that
became false. Exit codes verified by hand: 0 clean / 1 findings / 2 missing.

ruff clean, ruff format clean, mypy clean.
…e was false

Accepted 2026-08-01, built and merged the same day.

Section 7 originally proposed "a vault CI job runs the verifier against the real
data", reasoning from the vault having ci.yml, tests/ and pyproject.toml. Open
question 1 asked whether that CI actually executes.

It does not. The actions API reports EVERY vault workflow as disabled_manually --
CI, Security, CodeQL, backlog-hygiene, release. Last run 2026-07-27, and the two
vault PRs merged on 2026-08-01 both merged with zero checks. The estate was
switched off at the cutover to avoid duplicating public CI; that decision stands
and is not being reversed here.

A CI-only design would have shipped dead. Amended to what was actually built: a
vault pre-commit hook (works today, fires at authoring time, when the drift is
introduced) plus one narrow new workflow -- new workflows are active by default
even though the estate is disabled, so it runs without resurrecting any of it.

The lesson is this ADR's own thesis applied to itself: section 7 was a confident,
plausible statement about system state that nobody had checked. It was caught
only because ratification was gated on answering the open question rather than on
the argument reading well.
…tuted away

The row was written through a shell double-quoted string, so bash treated
`disabled_manually` as a command substitution and replaced it with the empty
string. The row shipped reading "every vault workflow  (last run 2026-07-27...)"
-- the load-bearing word silently deleted.

Fitting failure mode for this ADR: the sentence still parsed, still read as
plausible, and stated nothing. Caught by grepping for the token rather than for
the sentence around it.
@wshallwshall
wshallwshall enabled auto-merge (squash) August 1, 2026 14:10
… a spec

Grounded in a deep-research pass over OWASP's own assessment chapter, NIST
OSCAL, and the one comparable OSS tool. Three findings changed the design.

FINDING 1: "Partial" is not an ASVS verdict. ASVS 5.0 defines exactly three --
verified, exception, and non-applicable-with-rationale. The strings "partially
implemented" and "not implemented" appear NOWHERE in it, and the only prominent
OSS ASVS tool declines to model partial too. Our three-way rubric was a local
extension we invented and never wrote down, which is precisely why 11.7.1, 3.7.3
and 5.4.3 each changed verdict in one day: two assessors, two unwritten rules.

  -> docs/ASVS-ASSESSMENT-METHOD.md now states the decision procedure as an
     ORDERED rule list with the seven real disputes as worked examples, so the
     next assessor reaches the same answer. ASVS's own stability mechanism is a
     disclosed repeatable method, not a rubric -- this is that disclosure.

FINDING 2: recording the reason for non-applicability is the ONE "must" in the
whole assessment chapter (everything else is "should"). So an `na` with no
rationale is not a lax entry; it is the single thing the standard requires,
omitted. load_scorecard() now REFUSES it.

FINDING 3: ASVS requirement ids are NOT stable across versions -- bare 1.2.5 is
Architecture in 4.0.3 and Encoding in 5.0.0 -- and master is the bleeding-edge
branch where a rolling "latest" release republishes identical filenames. Our
corpus was fetched from master and turned out byte-identical to the tagged
release: luck, not method. Now pinned by [scorecard].asvs_version plus a
corpus_sha256 the verifier recomputes and compares.

PHASE 0 -- the renderer no longer publishes a headline count. It leads with how
much of the survey is done. 76% of that day's verdict changes were cells nobody
had ever opened, so a count computed over unexamined cells is an average of
guesses, and publishing it is what made every first look read as a REVERSAL
rather than as PROGRESS. Reporting every requirement including `unverified` is
also what ASVS asks for -- a summary of all requirements checked, not exceptions
only.

Also adds `needs-review` (examined, contested, parked deliberately) and
`reviewed_by`. Separating "undecided" from "decided-and-failing" is a cheaper
stability primitive than inventing a grade -- borrowed from the one OSS tool in
this space, which pairs it with reviewed_by/reviewed_at.

OSCAL was evaluated and rejected for this use: ASVS has no OSCAL catalog
(proposed 2020, closed 2021 in a backlog sweep, still none in the 2026 release),
and assessment-results mandatorily imports an assessment-plan which mandatorily
imports an SSP -- a chain a product with no operated system has no source for.
Recorded in the method doc, with Component Definition and CycloneDX declarations
noted as the two unexplored alternatives.

24 tests (17 + 7 new), ruff clean, mypy clean.
The workflow rendered with --anchor-sha ${{ github.sha }} while the committed
entry point carried the code anchor, so the rendered file differed on the anchor
line on EVERY run. `git diff --exit-code` therefore failed unconditionally.

A gate that cannot go green is as useless as one that cannot go red, and this
session has spent all day on the second kind. I shipped the first kind, in the
tool built to prevent exactly this, and it went red on its own first PR.

The anchor is the commit the EVIDENCE was read on -- a property of the
assessment, not of the CI run -- so it belongs in [scorecard].anchor_commit and
is rendered from there. The render is now deterministic: running it twice
produces identical bytes, and the drift check becomes meaningful, catching a
scorecard edit that was not re-rendered.
The ASVS 11.1.3 discovery gate caught scripts/asvs/scorecard.py using hashlib
without an inventory entry. The gate is right: it is a discovery gate for exactly
this, and it fired on the first commit that introduced new crypto usage.

Declared in both places the convention requires -- the scanner INVENTORY and
ASVS-L2-PHASE0-CHANGES.md section 4 -- with what it is and, more importantly,
what it is NOT: SHA-256 over the ASVS corpus FILE, integrity of a build input,
no secret, no key, no message authentication, nothing user- or PHI-derived.

Recorded why it exists rather than just that it does: the corpus was originally
fetched from master, where a rolling "latest" release republishes identical
filenames, and matched the tagged v5.0.0_release asset only by luck. Since ASVS
requirement ids are not stable across versions -- bare 1.2.5 is Architecture in
4.0.3 and Encoding in 5.0.0 -- a corpus that moves silently re-points every id
in the scorecard, so the digest is now checked rather than assumed.
…ries

The crypto inventory is stated in FOUR places, not the two I updated:

  scripts/security/crypto_inventory_check.py  INVENTORY          (done)
  docs/ASVS-L2-PHASE0-CHANGES.md section 4                        (done)
  tests/test_key_usage_scope_inventory.py     key/not-key split   (missing)
  tests/test_security_static.py               out-of-package map  (missing)

Both now carry it, classified as NOT key material with the reason: a keyless
content hash over a build input, not a key, a secret, or a message authenticator.

Worth recording rather than quietly fixing. This is the same defect class the
whole ASVS effort has been about -- one fact stated in four places, so updating
two leaves the other two stale -- and the project's own doc standard says state
a load-bearing fact ONCE and link to it. Here the redundancy is load-bearing on
purpose (each registry asks a different question: what uses crypto, why, does it
hold a key, and is it inside the package), so the fix is not to collapse them.
But nothing tells an author that adding one row obliges four edits, and I found
out by reding CI twice.

102 tests pass across all five crypto/scorecard suites.
@wshallwshall
wshallwshall disabled auto-merge August 1, 2026 20:36
@wshallwshall
wshallwshall merged commit 8f01cef into main Aug 1, 2026
32 checks passed
@wshallwshall
wshallwshall deleted the claude/adr-asvs-scorecard-as-data branch August 1, 2026 23:59
wshallwshall added a commit that referenced this pull request Aug 2, 2026
The ADR-0154 session re-ran #119's windows-2025 leg on the SAME commit
against the SAME 26:00 cap. Attempt 1 was killed at the cap; attempt 2
concluded success. Same code, same config, same ceiling, two outcomes.

This closes the one gap in the case for raising the cap: it rules out "that
PR's tests are just slow". The leg was not failing, it was coin-flipping
against the ceiling -- which is precisely the state the ubuntu note above
already names, now demonstrated rather than argued.

It also disposes of "re-run it and see" as a diagnosis. A green re-run at
26:00 does not show the suite fits; it shows that runner was fast enough
that time. Recorded in the comment so the next person reaching for a retry
knows what a green retry does and does not prove.

Evidence contributed by the session holding #119, which is deliberately
holding its branch update until this lands so it re-rolls under the raised
cap rather than spending another coin flip at 26.

Merged main (8f01cef, #120) in the same push: #131 had gone BEHIND, which
is the stall this PR exists to report -- the fix for the cap has to survive
the cap, and the fix for silent stalls can itself stall silently.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
…s that can never merge (#131)

* ci: report pull requests that are green, armed, and can never merge

Measured on this repo 2026-08-01: nine open pull requests with zero failing
checks and zero pending checks, not one of which could merge. Six had
auto-merge ARMED, which will never fire. #74 had been in that state since
2026-07-30 and was found only because somebody went hunting for "stuck CI"
by hand.

The mechanism is that `strict = true` plus no merge queue plus a ~20-minute
suite makes merging a race: a PR is mergeable only between going green and
the next thing landing on main. Losing that race is silent. Armed auto-merge
does NOT update a BEHIND branch -- it waits on checks that already passed --
so the PR sits with no failing check, no notification, and no run in flight.

No existing signal can see it, because every existing signal is a check
OUTCOME and nothing has failed. nightly-notice.yml watches CI runs and there
is no failing run to watch; the author's last signal was a full pass. A green
dashboard and a wedged repository are indistinguishable unless something asks
"can this still merge at all?".

This does not fix the race -- only a merge queue does, filed separately as
BACKLOG #340. It converts a SILENT failure into a LOUD one, which is the part
that let #74 sit for days.

Scheduled rather than per-PR: the stall arrives when a DIFFERENT pull request
merges, so the affected PR has no run to hang a check on. Advisory by
placement and must never become required -- it reports on OTHER pull requests,
so a stall on #71 would block #128, wedging the repo with the tool meant to
unwedge it.

Verified against the live repo: 14 scanned, 8 stalled, 6 armed, exit 1. The
count differs from the hand survey's 9 because #120 was re-synced in between,
which the check correctly excluded. Tests carry a positive control (the exact
stall shape MUST be detected) alongside negative controls for failing,
pending, BLOCKED, DIRTY and closed PRs, and assert that an unclassifiable
rollup node counts as unsettled rather than green.

* ci: raise the Windows step cap, which had 1.06x margin while claiming 2x

PR #119 was killed at 26:07 against ci.yml's 26:00 `step_timeout` with ZERO
tests failing. What moved was the suite, not the code under test: #74 landed
tests/test_worktree_prune_merged.py (1,506 lines) and windows-2025 went
19:35 -> 26:07 on the same branch.

The comment beside the cap said the Windows legs were "unchanged because 26
min against the same suite is still ~2x headroom". Measured over the 11
PASSING windows-2025 runs on 2026-08-01:

  leg              max passing step   old cap   old margin
  ubuntu-latest    12:27              19:00     1.53x
  windows-2022     18:39              26:00     1.39x
  windows-2025     24:35              26:00     1.06x

windows-2025 had already PASSED at 24:35 -- 85 seconds of margin -- before
#119 died. The "2x" figure matched no leg when it was written. The same file
records this exact failure happening on the ubuntu leg on 2026-07-31 (775s
green against a 780s cap) and concludes a watchdog that cannot separate
"deadlocked" from "slow today" becomes a coin flip; ubuntu's budget was
raised then and Windows was left alone on the false claim.

Raised to step_timeout 36 (1.46x over the 24:35 max) and job_timeout 40,
preserving the nesting invariant that the step must expire strictly before
the job. Both Windows legs take the same number: windows-2022 is faster, so
sizing on windows-2025 only leaves it more room. The replacement comment
states the measured value and its date rather than a multiple -- a bare
multiple is what let this rot undetected.

Timing note recorded in the comment because it cost two sessions an error
during triage: step_timeout gates the STEP, not the job. c53f752's JOB ran
28:41 and PASSED, against job cap 30 / step cap 26.

Also files BACKLOG #340 (enable a merge queue -- 9 green PRs could not merge,
6 armed and never firing) and #344 (fixed wall-clock bounds as a class, with
this cap as instance 1 and test_stage_dispatcher.py's hardcoded 8.0s poll
budget against an injected ManualClock as instance 2).

COORDINATION: ci.yml and docs/BACKLOG.md were each held by another live
session, and the collision gate (scripts/hooks/collision_gate.ps1) refuses an
Edit while a live session's BRANCH carries a diff to the file -- it cannot
represent "coordinated, verified disjoint". Both counterparties gave explicit
written consent before these edits were applied outside the Edit tool:
zizmor-1280-adoption ("You land it. I'm standing down on ci.yml timeouts",
its only hunk being a one-line pin comment ~160 lines away) and
ha-construct-pickle-sandbox ("Go ahead with #340 now -- append after #338
exactly as you planned. I'll absorb the conflict"). No hooks were skipped;
this commit ran the full pre-commit suite.

* ci: record that #119's leg passed on a re-run at the same cap

The ADR-0154 session re-ran #119's windows-2025 leg on the SAME commit
against the SAME 26:00 cap. Attempt 1 was killed at the cap; attempt 2
concluded success. Same code, same config, same ceiling, two outcomes.

This closes the one gap in the case for raising the cap: it rules out "that
PR's tests are just slow". The leg was not failing, it was coin-flipping
against the ceiling -- which is precisely the state the ubuntu note above
already names, now demonstrated rather than argued.

It also disposes of "re-run it and see" as a diagnosis. A green re-run at
26:00 does not show the suite fits; it shows that runner was fast enough
that time. Recorded in the comment so the next person reaching for a retry
knows what a green retry does and does not prove.

Evidence contributed by the session holding #119, which is deliberately
holding its branch update until this lands so it re-rolls under the raised
cap rather than spending another coin flip at 26.

Merged main (8f01cef, #120) in the same push: #131 had gone BEHIND, which
is the stall this PR exists to report -- the fix for the cap has to survive
the cap, and the fix for silent stalls can itself stall silently.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
… not deliver

`docs/adr/README.md:121` said ADR 0087 "Closes the WP-L3-17 residual
(residual-closure)" while `:175` (ADR 0144) said "15.2.5 stays **Partial**". They
contradicted, and :175 was right.

Two separate reasons the closure claim was wrong, and only one of them is fixed:

1. Until BACKLOG #339 the IPC pipe pickled, so a Handler's `__reduce__` executed in
   the engine parent and the boundary was bypassable outright. The row asserted a
   closure the transport did not deliver. That part is now fixed.
2. Even with the codec, ADR 0087 confines the ADDRESS SPACE, not the host. Verified
   rather than inferred: `DEFAULT_FORBIDDEN_MODULES` blocks socket/ssl/asyncio/
   multiprocessing and the engine's secret-bearing packages, but NOT `os` or
   `subprocess` — a sandboxed Handler still reaches host command execution. OS-level
   default-deny is ADR 0147, still Proposed with no code.

So the row now records the MFW2 amendment, states 15.2.5 stays Partial in agreement
with the 0144 row, names the address-space limit explicitly, and adds the
address-space-only and #342 (grandchild not reaped) residuals. Module list gains
`_sandbox_codec.py`.

This edit was blocked earlier by the worktree guard while
`claude/adr-asvs-scorecard-as-data` held the file; it merged as `8f01cef8` (ADR 0156,
PR #120) and the guard released. Their merge did not touch the contradiction.

NOT included: BACKLOG #339's OPEN item 2 still reads "the edit was NOT made", and
#197's banner carries the same original closure claim. `docs/BACKLOG.md` is currently
held by two live sessions (adr-0154-sync-reply-handoff, stuck-cis) — I handed that
file to the first of them earlier and am not overriding the guard to take it back.
Both are text-accuracy follow-ups, not correctness ones.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
… not deliver

`docs/adr/README.md:121` said ADR 0087 "Closes the WP-L3-17 residual
(residual-closure)" while `:175` (ADR 0144) said "15.2.5 stays **Partial**". They
contradicted, and :175 was right.

Two separate reasons the closure claim was wrong, and only one of them is fixed:

1. Until BACKLOG #339 the IPC pipe pickled, so a Handler's `__reduce__` executed in
   the engine parent and the boundary was bypassable outright. The row asserted a
   closure the transport did not deliver. That part is now fixed.
2. Even with the codec, ADR 0087 confines the ADDRESS SPACE, not the host. Verified
   rather than inferred: `DEFAULT_FORBIDDEN_MODULES` blocks socket/ssl/asyncio/
   multiprocessing and the engine's secret-bearing packages, but NOT `os` or
   `subprocess` — a sandboxed Handler still reaches host command execution. OS-level
   default-deny is ADR 0147, still Proposed with no code.

So the row now records the MFW2 amendment, states 15.2.5 stays Partial in agreement
with the 0144 row, names the address-space limit explicitly, and adds the
address-space-only and #342 (grandchild not reaped) residuals. Module list gains
`_sandbox_codec.py`.

This edit was blocked earlier by the worktree guard while
`claude/adr-asvs-scorecard-as-data` held the file; it merged as `8f01cef8` (ADR 0156,
PR #120) and the guard released. Their merge did not touch the contradiction.

NOT included: BACKLOG #339's OPEN item 2 still reads "the edit was NOT made", and
#197's banner carries the same original closure claim. `docs/BACKLOG.md` is currently
held by two live sessions (adr-0154-sync-reply-handoff, stuck-cis) — I handed that
file to the first of them earlier and am not overriding the guard to take it back.
Both are text-accuracy follow-ups, not correctness ones.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
… reason

Routed here by the ADR 0154 session because I was the one live in this
file. I had already corrected the false half -- "#119 never merged" -- but
only to "it merged the following day", and their framing is better, so this
takes theirs.

The failure was never that the condition could not arrive. #119 merged
(2026-08-02 01:45:00Z, 002be18). It is that THE WORLD MOVED WHILE EVERYONE
WAITED: main advanced four times first -- #74 20:27:03Z, #120 23:59:43Z,
#131 00:35:29Z, #130 01:01:35Z. So the freeze did not hold main still even
while nominally in force. It held only the sessions honouring it, which is
the worst of both, and it is a sharper argument for the same bullet without
resting on a false fact.

Every timestamp re-verified against the API here rather than restated; the
measurements are theirs. The same framing was independently corrected in
ci.yml (07b6e55) and in BACKLOG #340, making this the third document to
carry it and the last one live.

Also names what the bullet had become: a compensating control resting on a
false premise, which is the failure CLAUDE.md §11 lists -- occurring inside
the document that argues for the rule. That is worth one sentence, because
the next stale premise will look just as settled as this one did.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
…heir sources

I omitted both for want of a source; the ADR 0154 session found both and I
re-ran each before taking it.

  12h15m  #119's auto-merge armed 2026-08-01 13:29:37Z, merged 01:45:00Z.
          The timeline event is `auto_squash_enabled` -- a filter on
          `auto_merge_enabled` returns nothing, which is why the wait looked
          unmeasurable. Recorded in the doc, since the next person to look
          will reach for the wrong event name too.
  8m26s   the claim declaring the freeze is stamped 2026-08-01 23:51:17Z;
          #120 merged 23:59:43Z.

The second is hedged in the doc, and their caveat was the right one: `claimed`
records when the KEY was taken, not when the NOTE was written. What tightens
it is that `refreshed` is ABSENT on that claim -- and on the code of the day
there was no way to edit a note in place at all, so the two coincide unless
someone hand-edited the JSON. Stated as "the claim was taken at", which is
what the argument needs and no more.

That claim is still on the board, still announcing the freeze, which is why
it is cited in the present tense.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
… child (BACKLOG #339, ADR 0087) (#136)

* fix(sandbox): the engine parent executed code chosen by the sandboxed child (ADR 0087)

ADR 0087's opt-in Router/Handler subprocess isolation exists to put an address-space
boundary between admin-authored code and the engine's DEK, audit chain and sockets.
The boundary was bypassable: the child pickled a Handler's RAW return value into
{"ok": True, "result": ...} and the parent called pickle.loads on it. A Handler
returning an object with a custom __reduce__ therefore executed arbitrary code in the
engine process, defeating the boundary entirely.

The file already reasoned about "a compromised worker", and named engine-side
handler/outbound-NAME validation as the defense -- but that runs long after
pickle.loads has executed the payload. The `nosec B301 / nosemgrep` suppression that
kept the scanners quiet was justified as "never external/untrusted data", which is
true for the child reading the parent and false for the parent reading the child.

Replace pickle on BOTH legs with a segmented, closed-tag, non-executing codec
(_sandbox_codec.py): the decode path is json.loads with no object hooks plus a
literal tag dispatch over a closed constructor set, so it cannot name a type, import
a module, getattr on child data, or reach __reduce__. A restricted unpickler was
evaluated and disproved -- a BUILD opcode over an allowlisted frozen dataclass yields
Send(to=42, message=[]) with __post_init__ never running.

Adversarial review then found a second, separate break in the correlation defense the
codec introduced: the request id was a per-spawn nonce plus a counter, disclosed to
the child, and decode_response checked only (id, phase). A Handler could pre-stage a
forged frame for the next id and have it consumed as an unrelated message's result --
silent misdelivery with no ERROR and no disposition. Closed three ways: a fresh
secrets.token_hex(16) per dispatch, binding of the whole (id, phase, name) triple, and
an unsolicited-frame check that is fatal to the worker.

mode=off stays the default and byte-identical. Measured against a HEAD baseline, the
codec is a net correctness win (10 divergences -> 3, two of those pre-existing) and
fixes mode=subprocess being outright DOA for ADR 0013 loopback re-ingress.

Suppression hygiene: the `nosec B403` and the repo's only `nosemgrep` are deleted,
not reworded -- no pickle/marshal remains in messagefoundry/ or tee/.

Residuals are stated honestly in ADR 0087 rather than left implicit, including a
~1.2-1.4x marshalling cost on large reference tables and a documented mode divergence
for a contract-violating mutating Router (pre-existing; pinned by a test that asserts
the inequality on purpose).

No ADR or BACKLOG number allocated -- prose only, banners untouched.

* docs(sandbox): file BACKLOG #339, de-pickle the drift anchor, pin ADR 0147 to the codec

Ledger and doc follow-ups to c0d61b94 (the ADR 0087 MFW2 sandbox codec fix).

BACKLOG #339 — allocated atomically via scripts/coord/alloc.ps1, never grepped.
Records the defect, the second break adversarial review found in the correlation
defense the codec introduced, the measured net-correctness win over the pickle
baseline, and the residuals. Carries three OPEN owner decisions rather than
pretending they are settled: the erratum/advisory call, the ADR README closure
contradiction, and the private-vault doc pass.

test_threat_model_doc_drift — the 15.1.5 "Sandbox IPC deserialization" row pinned
the literal token `pickle`. After the fix that assertion would REQUIRE the private
doc to keep asserting a mechanism the code no longer has, so the anchor moves to
`_sandbox_codec`. Note these 89 drift tests skip in EVERY checkout: /docs/security/
is gitignored with zero tracked files, so CI cannot catch this drift in either
direction and the vault edit is a manual coupled follow-up. (This also corrects an
earlier claim that the coupling reds CI — it does not.)

ADR 0147 — its IPC request-broker was designed against the pickled pipe. The
broker's direction of travel is child-requests/parent-acts, precisely the direction
that must never name a callable, so a broker on the old pipe would have re-opened
the hole it exists to help close. The dependency on the closed grammar is now
explicit, along with a note that 0147's Context understated the residual because it
predates the defect.

NOT changed: docs/adr/README.md:121 still claims "Closes the WP-L3-17 residual"
while :175 says 15.2.5 stays Partial. :175 is correct — confinement is address-space
only until ADR 0147 lands. The worktree guard blocked the edit because a live
session (claude/adr-asvs-scorecard-as-data) is concurrently rewriting that file's
ASVS claims, and overriding it would have cost one of us the work. Recorded as OPEN
item 2 under #339. BACKLOG #197's banner carries the same claim and was also left
alone — not this item's to edit.

* backlog: file three findings the sandbox codec review surfaced but did not fix

Spun out of the ADR 0087 MFW2 review (c0d61b94, #339) rather than folded into it —
each changes behaviour outside the security fix's scope, and bundling them would have
made a security change harder to review and harder to revert.

341 — a Handler returning a TUPLE or SET of Sends delivers nothing, silently.
`_partition` narrows with `items = result if isinstance(result, list) else [result]`,
so a non-list container becomes the single item, matches no isinstance filter, and
yields ([], [], []). Verified by direct execution: _partition((send, send))[0] == [].
The message then finalizes FILTERED — a LEGITIMATE disposition — so it is
indistinguishable from a handler deliberately declining, and no ERROR is raised. That
is an accept-and-drop (CLAUDE.md §12), and it is P1 for that reason: the
count-and-log invariant is satisfied on paper while the operator is misinformed.
Pre-existing; the codec deliberately preserves it so both modes agree.

342 — `_kill` calls proc.kill(), which reaps only the direct child. A grandchild
spawned by Handler code inherits fd 1 (the response pipe) and survives. Bounded but
NOT closed by #339's correlation fix: the unguessable per-dispatch id and the
unsolicited-frame check stop a forged answer, so the residual is availability and
orphan-process hygiene, not misdelivery. Wants a job object on Windows and a process
group on POSIX — the same platform asymmetry ADR 0147 already carries, so they should
be designed together.

343 — the worker is spawned with stderr=None, so the child's stderr IS the engine's,
unframed and unattributed. Two separable problems: attribution (a Handler line is
indistinguishable from an engine line) and PHI (a Handler that prints a body writes a
full payload into the general log, which CLAUDE.md §9 forbids at INFO and above). The
sibling stdout hazard is noted in the same item: a print() to fd 1 happens not to
corrupt a frame only because it lands in a different buffer — luck, not design.

All three numbers allocated atomically via scripts/coord/alloc.ps1, never grepped.
Left UNCLAIMED on purpose: filing is not building, and any session should be able to
pick them up. Docs-only; the one BACKLOG status-invariant failure is the pre-existing
#320, untouched and owned elsewhere.

* docs(adr): the 0087 index row claimed a residual closure the pipe did not deliver

`docs/adr/README.md:121` said ADR 0087 "Closes the WP-L3-17 residual
(residual-closure)" while `:175` (ADR 0144) said "15.2.5 stays **Partial**". They
contradicted, and :175 was right.

Two separate reasons the closure claim was wrong, and only one of them is fixed:

1. Until BACKLOG #339 the IPC pipe pickled, so a Handler's `__reduce__` executed in
   the engine parent and the boundary was bypassable outright. The row asserted a
   closure the transport did not deliver. That part is now fixed.
2. Even with the codec, ADR 0087 confines the ADDRESS SPACE, not the host. Verified
   rather than inferred: `DEFAULT_FORBIDDEN_MODULES` blocks socket/ssl/asyncio/
   multiprocessing and the engine's secret-bearing packages, but NOT `os` or
   `subprocess` — a sandboxed Handler still reaches host command execution. OS-level
   default-deny is ADR 0147, still Proposed with no code.

So the row now records the MFW2 amendment, states 15.2.5 stays Partial in agreement
with the 0144 row, names the address-space limit explicitly, and adds the
address-space-only and #342 (grandchild not reaped) residuals. Module list gains
`_sandbox_codec.py`.

This edit was blocked earlier by the worktree guard while
`claude/adr-asvs-scorecard-as-data` held the file; it merged as `8f01cef8` (ADR 0156,
PR #120) and the guard released. Their merge did not touch the contradiction.

NOT included: BACKLOG #339's OPEN item 2 still reads "the edit was NOT made", and
#197's banner carries the same original closure claim. `docs/BACKLOG.md` is currently
held by two live sessions (adr-0154-sync-reply-handoff, stuck-cis) — I handed that
file to the first of them earlier and am not overriding the guard to take it back.
Both are text-accuracy follow-ups, not correctness ones.

* test(sandbox): the dead-peer test raced its own reader thread

`test_a_dead_peer_is_not_treated_as_a_forged_frame` failed in CI on ubuntu AND on
windows-2022 while passing on a local Windows box. Not a flake and not a product
defect -- a real ordering race in the test, reproduced deterministically before
being fixed.

The test kills the worker, then plants a frame in the response queue and asserts
`_reject_unsolicited` rejects it. But killing the worker makes that generation's
reader thread hit EOF on the now-closed stdout and push a `_EOF` of its OWN.
`_reject_unsolicited` drains exactly ONE item, so whenever that EOF lands first
the queue is [_EOF, frame]: the benign corpse signal is consumed, the check
correctly does not raise, and the assertion fails for a reason that has nothing
to do with the frame.

Which side of the race you land on is pure scheduling, NOT platform. An earlier
draft of this message blamed Linux teardown speed; that was wrong -- windows-2022
failed the same way, and only the local machine happened to lose the race
consistently enough to look green. The fix removes the ordering dependency
entirely rather than making either side faster, so it does not rely on that
diagnosis being right.

Proven by driving the queue directly:

    [frame]         -> raised SandboxError
    [_EOF, frame]   -> did NOT raise      <- the CI failure
    [_EOF]          -> did NOT raise

Fix: rebind the queue before planting the frame, which is exactly what `_spawn`
already does and for the same stated reason -- "a fresh response queue per spawn
so a prior (killed) worker's trailing EOF can't leak into this generation's
reads." The test was reaching across a generation boundary the production code is
careful never to cross.

PRODUCTION IS UNAFFECTED, and the path was checked rather than assumed. If a
stray frame ever does sit behind an `_EOF`, `_reject_unsolicited` kills the
worker, `_ensure_proc` then finds `_proc is None` and respawns, and `_spawn`
rebinds the queue -- discarding the leftover. Worth stating plainly that the
guarantee comes from the fresh-queue-on-spawn, NOT from the drain check the
docstring leads with; the drain sees one item, and a grandchild holding the
inherited fd 1 could in principle write after the child's EOF (#342). Draining
the whole queue would make the check self-sufficient instead of dependent on a
respawn elsewhere. Deliberately NOT changed here: it is a behaviour change on a
security path and belongs with #342, not appended to a PR mid-CI.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
#140)

* fix(coord): overlap gave two different answers the same bytes, twice

Two defects in one script, and they are the same defect: a signal that
cannot distinguish the state it reports from a different state.

1. A -Json query answered "nobody else is in this file" by printing
   NOTHING. `@() | ConvertTo-Json -AsArray` sends zero objects down the
   pipeline, so ConvertTo-Json never runs -- -AsArray only shapes output
   that already exists. On stdout an all-clear was therefore byte-for-byte
   identical to the script dying before it answered, and no consumer could
   tell them apart. Every -Json exit now goes through one emitter that
   always produces an array. (-InputObject is not the fix: with -AsArray it
   double-wraps to [[]].)

   Found by running the real script against the real collision gate rather
   than the test stubs, which had been written to a shape the real script
   never produced.

2. A live session was attributed to a worktree by FIRST prefix hit. Linked
   worktrees live under the primary checkout, so every linked path is also
   a prefix match for the primary's row: the primary was handed whichever
   nested session the hash table enumerated first, and reported LIVE on
   main, "building" a peer's task list. Hash order is not stable, so it was
   a different wrong answer each run -- which is why it read as noise
   rather than as a bug. Longest prefix wins is the only rule that survives
   nesting, and it is resolved once against every worktree instead of per
   row.

   docs/WORKTREES.md already named this exact trap for the announce hook's
   id rule, where the cure was "never match by prefix". Here a prefix match
   is genuinely required -- a session may sit in any subdirectory -- so the
   cure has to be longest-prefix instead.

Both are pinned against a real nested-worktree git fixture; a sibling
layout would pass under the old rule and prove nothing. Each new assertion
was checked against the unfixed script first: the attribution test reports
the primary as Live/main/<peer session id>, and the array test sees ''.

* fix(coord): the collision gate reported an all-clear when it had checked nothing

Every fail-open path in this hook -- overlap script missing, throwing, or
printing garbage -- exited 0 with EMPTY STDOUT. On a PreToolUse hook whose
stdout is parsed as a decision, empty stdout means "allow", which is
byte-for-byte what "checked, nobody else is in this file" looks like. So a
gate that had consulted nothing was indistinguishable from a gate reporting
all-clear, and its own failure reached the session as reassurance.

That is the silent-control class this repo has now hit five times, and it
is the same shape as the wired-but-inert announce shim: the surface that
was supposed to report sat downstream of the failure it existed to detect.

The posture does not change -- every one of these paths still ALLOWS. Only
the silence does. It now emits a hookSpecificOutput.additionalContext
notice naming which reason (overlap-missing / overlap-failed /
overlap-empty / overlap-unparseable / payload-unreadable). It must be that
JSON shape and never a bare line: this hook's stdout is a decision, so a
stray line risks a misparse on every Edit and Write -- a diagnostic that
would be a worse fault than the one it reports. There is deliberately no
permissionDecision key: a notice that blocked would invert the fail-open
posture that is the whole point of this gate.

Rate-limited per reason (30 min, -NoticeCooldownMinutes) so a persistently
broken overlap cannot narrate itself into every edit -- this gate's own
docstring records where a gate that cries wolf ends up. The stamp lives
under -StateDir, defaulting to the repo's coordination dir and resolved
ONLY when about to report, so nothing new runs on the hot path. If the
stamp cannot be read or written the notice is emitted anyway: the failure
mode of a noise-suppressor must be noise, never quiet, or an unwritable
directory silently restores exactly the behaviour this removes.

Distinguishing overlap-empty from a resolved "nobody" required fixing the
producer first (previous commit) -- you cannot detect a difference the
producer never encoded. Verified against the real overlap script, not only
the stubs: an ordinary edit to an untouched file is silent.

Tests: -StateDir isolates the throttle per test, or the first notice would
silence the next test's and the suite would pass on run order.

* fix(coord): claim.ps1 accepted a new note, reported success, and discarded it

-Take documented itself as idempotent -- "re-taking your own claim just
refreshes the note" -- and did not refresh anything. A new -Note was taken,
acknowledged and dropped.

That is worse than an outright failure, because of what the note is for.
It is the only field written deliberately to say what a session is doing,
and announce-session.ps1 broadcasts it to every session joining the repo
while telling them to prefer it over the worktree name. So the one field
elevated to authoritative was the one field that could not be corrected.
Measured 2026-08-02: a claim note was still announcing "NO PR OPENED --
honouring the #119 merge freeze" to every joining session hours after both
that PR and the one it gated had merged.

The workaround people reached for -- -Release then -Take -- drops the claim
in between, re-opening the race the claim exists to close.

Re-taking a key you hold now rewrites the file in place: note, branch (a
worktree can have switched branches, and a claim naming a branch nobody is
on is another confidently-wrong coordination fact) and a new `refreshed`
stamp, leaving `claimed` untouched -- which is what proves the claim was
never let go. Write-then-rename, not a truncating write: claim_check.py
swallows a JSON parse error into "not claimed", so a torn file is a
silently disabled gate, and a crash mid-refresh must leave the old note.
Mutual exclusion is unchanged and pinned: a peer's key is still refused.

One trap found by the test rather than by reading. ConvertFrom-Json
silently coerces an ISO-8601 string to [datetime], so [string]$c.claimed
returns the local short form -- sub-second precision and UTC offset gone.
Writing that back would have downgraded the stamp on every refresh, and it
would still have parsed, so nothing would ever have complained. Stamps now
round-trip through "o", and the test asserts byte equality rather than
"still parses". The same coercion is handled where announce reads it, with
an invariant-culture parse for the string case.

announce-session.ps1 now prints each claim note's AGE (from `refreshed`
else `claimed`, "age unknown" when it cannot be determined -- an unknown
age must not render as a fresh one). Elevating a note to authoritative
makes a stale one strictly more dangerous than none, and age is the cheap
signal that lets a reader discount it.

Not taken here: claim -List's staleness-vs-liveness rendering, which is
already open as its own change.

* docs(coord): record the three fixes, and correct a claim that has expired

SESSION-DRIFT-CONTROLS.md: a fifth instance of the silent-control class,
in the collision gate itself, added to the callout that names the class.
It carries the part worth reusing -- the fix was not "check harder", it was
giving two states different bytes, and the first attempt failed because the
PRODUCER had never encoded the difference. Status-table rows for the three
controls, and the claim-refresh behaviour beside claim.ps1's entry.

WORKTREES.md: the announce id rule already warned that a prefix match
resolves a peer in the primary to an arbitrary worktree session, because
every worktree cwd extends the primary's. overlap.ps1 had that same trap
live at the same time. Noted there, with the distinction that matters:
overlap genuinely needs a prefix match, so the cure is longest-prefix
rather than exact-match.

And a correction. The broadcast-constraints list said of last week's merge
freeze that "#119 never merged (it died on an unrelated CI timeout)". It
merged the following day, 2026-08-02 01:45Z. Verified against the API
rather than restated. The lesson is unchanged and in fact sharper: the
recipients could not evaluate the predicate, so the freeze outlived its own
condition in both directions -- five sessions held while it had not
arrived, and a claim note was still announcing it hours after it had.

* docs(coord): announce-on-join merged and was never installed

Found while checking a peer session's report, not by looking for it. That
session announced itself by hand on 2026-08-02 and gave the reason as "the
hook is on an unmerged branch". It had merged (#133, 3389aa2) hours
earlier, so the observation was right and the diagnosis was not, and
nothing would have corrected it.

Measured across all five config roots:

  - no `mefor-announce` UserPromptSubmit entry anywhere
  - the one UserPromptSubmit entry installed is `# mefor-web-announce`,
    which resolves scripts/hooks/announce.ps1 -- a different script in a
    different repo, and one the installer's own comment already warns is
    easy to confuse with this marker
  - <git-common-dir>/mefor-coord/announce/ does not exist, so there is not
    a single receipt: it has never executed

install-coordination.ps1 was last run before the announce row existed, and
merging a hook does not install one. Its two other entries -- the
SessionStart banner and the collision gate -- were wired then and are
present, which is precisely why nothing looked wrong.

The part worth carrying: the missing-script notice was built so this class
could not hide, and it CANNOT FIRE when the hook is not wired at all,
because it lives inside the shim. Same shape as the defect this document
already records one level down -- the detector sat downstream of the
failure it existed to detect. So the status table now distinguishes rule
4's inert-BY-DESIGN from this one's inert-BY-ACCIDENT, and the confirmation
step is a receipt on disk rather than a reading of the settings file.

Not installed here: that writes ~/.claude/settings.json, which is shared
with every session on this machine. Owner's call, from a plain terminal.

* fix(coord): five defects this PR's own first pass introduced or left

Found by an adversarial review of the preceding commits, then each one
reproduced by execution before being touched. Two were regressions I had
introduced; three were gaps.

1. THE CLAIM FILE'S EXISTENCE IS THE LOCK, and the refresh unlinked it.
   `Move-Item -Force` is delete-then-rename. The take path is an exclusive
   CreateNew, so any instant the name does not exist is an instant another
   worktree can claim a key we hold -- i.e. the note refresh could hand a
   claim away. Measured on this box: 400 moves left the destination absent
   on 2,559 of 154,506 polls. [IO.File]::Move with overwrite is
   MoveFileEx(MOVEFILE_REPLACE_EXISTING), and the same harness never once
   saw the name missing across 134,581 polls. It fails transiently instead
   (13.5% under back-to-back churn, nothing like one refresh per run), so
   it retries five times and then reports; failing is the safe direction --
   the old note survives and the claim stays ours.

   The catch around it is deliberately UNTYPED: PowerShell wraps a .NET
   method's exception in a MethodInvocationException, so the typed catch I
   wrote first never matched, the failure escaped to ErrorActionPreference
   = Stop, and the temp file was orphaned in the claim registry. The
   orphaned-temp assertion is what caught it.

2. `overlap.ps1 -Json` emitted `[null]` for an empty map. Build-Map returns
   AutomationNull, which PARAMETER BINDING converts to a real $null at the
   call -- and `@($null).Count` is 1, so the zero-rows guard was dead in
   exactly the case it was added for and the whole-map query printed a
   phantom row. Strictly worse than the nothing it replaced. The -File path
   I had verified by hand was fine; the two call sites do not fail alike.

3. The unresolved-notice throttle was repo-wide. The stamp lives in the
   SHARED git-common-dir and production invokes the gate with no arguments,
   so the first session to hit a broken gate silenced it for every other
   session -- and those sessions read that silence as "checked, nobody is
   here", which is the precise defect the notice exists to remove. One
   session's diagnostic must never become another's false all-clear. Keyed
   per worktree now.

4. An empty payload or a literal `null` on stdin does not throw, so that
   was the one unreadable-input path still exiting silently.

5. A ghost session could outrank a live one. UNVERIFIED is the shape a
   crashed session's record takes once its pid is recycled; last-write-wins
   had no opinion about which record it kept for a directory, so a ghost
   could supply the id and branch reported for a worktree somebody is
   really sitting in. Fenced records now win, then sorted cwd.

Each fix is pinned, and the two regressions were checked against the
unfixed code: the phantom-row test sees `[null]`, and the claim test
asserts the file name never disappears while a refresh is failing.

* docs(worktrees): "is it live yet" has two answers, and they are different

I broadcast a merged claim.ps1 improvement to seven sessions as something
they could use immediately. A peer tried it, got the old behaviour, and
measured why: claim.ps1 is invoked BY HAND from the session's own worktree,
so it runs that worktree's copy, and their branch predated the change. The
in-force check I had given them was for the hook-run path and returned 0
for them.

Both halves of what I said were individually true. The combination was
wrong, because there are two rules and I collapsed them into one:

  hook-run   (collision_gate.ps1, and overlap.ps1 as its callee) -- the
             installed shim resolves the PRIMARY first, so it is live when
             the primary advances, whatever any branch contains
  hand-run   (claim.ps1, overlap.ps1, presence.ps1) -- resolved from the
             session's OWN tree, so it is live when that branch has it,
             and the primary is irrelevant

Tabulated, with the check spelled out per path. The point generalises past
this PR: test the property where the script will actually run from, because
a token that resolves in the primary says nothing about a hand-run script.

Also surfaces `collision_gate.ps1 -PathOverride <path>` as the read-only
"who holds this file right now" query. It is documented in-script only as a
test affordance, and the peer above found it by reading the source after it
answered a question nothing else would.

Both points are theirs, not mine.

* docs(worktrees): the freeze bullet had the right lesson and the wrong reason

Routed here by the ADR 0154 session because I was the one live in this
file. I had already corrected the false half -- "#119 never merged" -- but
only to "it merged the following day", and their framing is better, so this
takes theirs.

The failure was never that the condition could not arrive. #119 merged
(2026-08-02 01:45:00Z, 002be18). It is that THE WORLD MOVED WHILE EVERYONE
WAITED: main advanced four times first -- #74 20:27:03Z, #120 23:59:43Z,
#131 00:35:29Z, #130 01:01:35Z. So the freeze did not hold main still even
while nominally in force. It held only the sessions honouring it, which is
the worst of both, and it is a sharper argument for the same bullet without
resting on a false fact.

Every timestamp re-verified against the API here rather than restated; the
measurements are theirs. The same framing was independently corrected in
ci.yml (07b6e55) and in BACKLOG #340, making this the third document to
carry it and the last one live.

Also names what the bullet had become: a compensating control resting on a
false premise, which is the failure CLAUDE.md §11 lists -- occurring inside
the document that argues for the rule. That is worth one sentence, because
the next stale premise will look just as settled as this one did.

* docs(worktrees): put the two numbers behind the freeze bullet, with their sources

I omitted both for want of a source; the ADR 0154 session found both and I
re-ran each before taking it.

  12h15m  #119's auto-merge armed 2026-08-01 13:29:37Z, merged 01:45:00Z.
          The timeline event is `auto_squash_enabled` -- a filter on
          `auto_merge_enabled` returns nothing, which is why the wait looked
          unmeasurable. Recorded in the doc, since the next person to look
          will reach for the wrong event name too.
  8m26s   the claim declaring the freeze is stamped 2026-08-01 23:51:17Z;
          #120 merged 23:59:43Z.

The second is hedged in the doc, and their caveat was the right one: `claimed`
records when the KEY was taken, not when the NOTE was written. What tightens
it is that `refreshed` is ABSENT on that claim -- and on the code of the day
there was no way to edit a note in place at all, so the two coincide unless
someone hand-edited the JSON. Stated as "the claim was taken at", which is
what the argument needs and no more.

That claim is still on the board, still announcing the freeze, which is why
it is cited in the present tense.

* docs(ledger): the CI backstop does not re-check ownership, and said it did

Found while unblocking another session that could not commit a rescued ADR:
its number is allocated to a worktree that is not theirs.

LEDGER-GATE.md §3 said "CI re-runs the same rules with --ci", and Limits
said the --ci leg "is the backstop, and it cannot be bypassed from a
branch". Both are true of every rule except the one a reader is most likely
to be relying on. ledger_check.py:196 and :241 are each guarded by
`not self.ci`, so "was this number allocated to you" runs LOCALLY AND NEVER
IN CI.

It has to be that way, and the reason is worth keeping: owns() reads the
allocation store from <git-common-dir>/mefor-coord/alloc, and a CI runner
clones fresh with no store, so the check would return False for every ADR
and no ADR could ever merge. This is not a bug to fix. It is a limit that
was documented as its own opposite.

The consequence is now stated rather than left as an inference: a green CI
on an ADR or BACKLOG PR is NOT evidence the number was allocated to anyone.
And the residual is bounded in both directions -- after --no-verify a number
belonging to another session's unmerged branch can be committed with nothing
objecting, but the collision rule still blocks whichever of the two merges
second. Late, loud and recoverable, rather than silent, which is the
property the gate was actually built for.

Same defect class as the freeze bullet corrected two commits ago, and as the
collision gate this PR started with: a compensating control resting on a
false premise -- CLAUDE.md §11 -- this time inside the document describing
the control.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
… (#148)

Three ledger corrections trailing PR #136 (55bbafb, the ADR 0087 sandbox codec fix).

1. #339 OPEN item 2 said "The edit was NOT made" about docs/adr/README.md:121.
   08d898b MADE it, in that same PR, once claude/adr-asvs-scorecard-as-data merged
   (ADR 0156, PR #120) and the worktree guard released. The item is CORRECTED rather
   than deleted: it read "was NOT made" for the life of the branch and at least four
   sessions saw it in that state, so the record should show what happened, not just
   end up right.

2. #197's banner still claimed "Closes the WP-L3-17 (ASVS 15.2.5) residual". That was
   the LAST copy of the claim in this file - README.md:121 was fixed by 08d898b and
   the private ASVS-L3-REMEDIATION-PLAN / THREAT-MODEL rows in the vault. Two reasons
   it is wrong, both verified against the code rather than inferred from the ADR:
     (a) DEFAULT_FORBIDDEN_MODULES blocks socket/ssl/asyncio/multiprocessing and the
         secret-bearing packages but NOT os/subprocess, so a sandboxed Handler still
         reaches host command execution - confinement is address-space only;
     (b) until #339 the pipe pickled the child's return and the ENGINE PARENT
         deserialized it, so the boundary was bypassable outright.
   Banner also gains _sandbox_codec.py, which it did not name.

3. #346 filed - the sandbox import boundary is enforced only at runtime, under an
   off-by-default flag. A type the child must construct or receive cannot live under a
   DEFAULT_FORBIDDEN_MODULES prefix; CapturedResponse violated that and made
   mode=subprocess + ADR 0013 loopback re-ingress DOA until #339 relocated it. The
   guard runs in the child, at dispatch time, only under mode=subprocess - so a
   re-violation gives a green suite, a byte-identical mode=off, and breakage ONLY for
   installs that enabled the sandbox for security reasons. It fails selectively against
   the population least able to report it.

   Number allocated via scripts/coord/alloc.ps1, never grepped.

   Measured for the item rather than asserted: `git grep -l FORBIDDEN_MODULES -- tests/`
   is EMPTY, and _sandbox_codec.py's imports are today all under config/ and parsing/,
   so the invariant currently HOLDS - this is about keeping it, not repairing it.

Verified by falsification, not by a green run: probing #346's banner to a non-banner
line failed the checker at exactly BACKLOG.md:8429 naming item #346, so the gate
demonstrably sees this item. Reverted; 269 items, each declaring exactly one status,
exit 0.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
…ore it is cut off (#152)

* fix(coord): overlap gave two different answers the same bytes, twice

Two defects in one script, and they are the same defect: a signal that
cannot distinguish the state it reports from a different state.

1. A -Json query answered "nobody else is in this file" by printing
   NOTHING. `@() | ConvertTo-Json -AsArray` sends zero objects down the
   pipeline, so ConvertTo-Json never runs -- -AsArray only shapes output
   that already exists. On stdout an all-clear was therefore byte-for-byte
   identical to the script dying before it answered, and no consumer could
   tell them apart. Every -Json exit now goes through one emitter that
   always produces an array. (-InputObject is not the fix: with -AsArray it
   double-wraps to [[]].)

   Found by running the real script against the real collision gate rather
   than the test stubs, which had been written to a shape the real script
   never produced.

2. A live session was attributed to a worktree by FIRST prefix hit. Linked
   worktrees live under the primary checkout, so every linked path is also
   a prefix match for the primary's row: the primary was handed whichever
   nested session the hash table enumerated first, and reported LIVE on
   main, "building" a peer's task list. Hash order is not stable, so it was
   a different wrong answer each run -- which is why it read as noise
   rather than as a bug. Longest prefix wins is the only rule that survives
   nesting, and it is resolved once against every worktree instead of per
   row.

   docs/WORKTREES.md already named this exact trap for the announce hook's
   id rule, where the cure was "never match by prefix". Here a prefix match
   is genuinely required -- a session may sit in any subdirectory -- so the
   cure has to be longest-prefix instead.

Both are pinned against a real nested-worktree git fixture; a sibling
layout would pass under the old rule and prove nothing. Each new assertion
was checked against the unfixed script first: the attribution test reports
the primary as Live/main/<peer session id>, and the array test sees ''.

* fix(coord): the collision gate reported an all-clear when it had checked nothing

Every fail-open path in this hook -- overlap script missing, throwing, or
printing garbage -- exited 0 with EMPTY STDOUT. On a PreToolUse hook whose
stdout is parsed as a decision, empty stdout means "allow", which is
byte-for-byte what "checked, nobody else is in this file" looks like. So a
gate that had consulted nothing was indistinguishable from a gate reporting
all-clear, and its own failure reached the session as reassurance.

That is the silent-control class this repo has now hit five times, and it
is the same shape as the wired-but-inert announce shim: the surface that
was supposed to report sat downstream of the failure it existed to detect.

The posture does not change -- every one of these paths still ALLOWS. Only
the silence does. It now emits a hookSpecificOutput.additionalContext
notice naming which reason (overlap-missing / overlap-failed /
overlap-empty / overlap-unparseable / payload-unreadable). It must be that
JSON shape and never a bare line: this hook's stdout is a decision, so a
stray line risks a misparse on every Edit and Write -- a diagnostic that
would be a worse fault than the one it reports. There is deliberately no
permissionDecision key: a notice that blocked would invert the fail-open
posture that is the whole point of this gate.

Rate-limited per reason (30 min, -NoticeCooldownMinutes) so a persistently
broken overlap cannot narrate itself into every edit -- this gate's own
docstring records where a gate that cries wolf ends up. The stamp lives
under -StateDir, defaulting to the repo's coordination dir and resolved
ONLY when about to report, so nothing new runs on the hot path. If the
stamp cannot be read or written the notice is emitted anyway: the failure
mode of a noise-suppressor must be noise, never quiet, or an unwritable
directory silently restores exactly the behaviour this removes.

Distinguishing overlap-empty from a resolved "nobody" required fixing the
producer first (previous commit) -- you cannot detect a difference the
producer never encoded. Verified against the real overlap script, not only
the stubs: an ordinary edit to an untouched file is silent.

Tests: -StateDir isolates the throttle per test, or the first notice would
silence the next test's and the suite would pass on run order.

* fix(coord): claim.ps1 accepted a new note, reported success, and discarded it

-Take documented itself as idempotent -- "re-taking your own claim just
refreshes the note" -- and did not refresh anything. A new -Note was taken,
acknowledged and dropped.

That is worse than an outright failure, because of what the note is for.
It is the only field written deliberately to say what a session is doing,
and announce-session.ps1 broadcasts it to every session joining the repo
while telling them to prefer it over the worktree name. So the one field
elevated to authoritative was the one field that could not be corrected.
Measured 2026-08-02: a claim note was still announcing "NO PR OPENED --
honouring the #119 merge freeze" to every joining session hours after both
that PR and the one it gated had merged.

The workaround people reached for -- -Release then -Take -- drops the claim
in between, re-opening the race the claim exists to close.

Re-taking a key you hold now rewrites the file in place: note, branch (a
worktree can have switched branches, and a claim naming a branch nobody is
on is another confidently-wrong coordination fact) and a new `refreshed`
stamp, leaving `claimed` untouched -- which is what proves the claim was
never let go. Write-then-rename, not a truncating write: claim_check.py
swallows a JSON parse error into "not claimed", so a torn file is a
silently disabled gate, and a crash mid-refresh must leave the old note.
Mutual exclusion is unchanged and pinned: a peer's key is still refused.

One trap found by the test rather than by reading. ConvertFrom-Json
silently coerces an ISO-8601 string to [datetime], so [string]$c.claimed
returns the local short form -- sub-second precision and UTC offset gone.
Writing that back would have downgraded the stamp on every refresh, and it
would still have parsed, so nothing would ever have complained. Stamps now
round-trip through "o", and the test asserts byte equality rather than
"still parses". The same coercion is handled where announce reads it, with
an invariant-culture parse for the string case.

announce-session.ps1 now prints each claim note's AGE (from `refreshed`
else `claimed`, "age unknown" when it cannot be determined -- an unknown
age must not render as a fresh one). Elevating a note to authoritative
makes a stale one strictly more dangerous than none, and age is the cheap
signal that lets a reader discount it.

Not taken here: claim -List's staleness-vs-liveness rendering, which is
already open as its own change.

* docs(coord): record the three fixes, and correct a claim that has expired

SESSION-DRIFT-CONTROLS.md: a fifth instance of the silent-control class,
in the collision gate itself, added to the callout that names the class.
It carries the part worth reusing -- the fix was not "check harder", it was
giving two states different bytes, and the first attempt failed because the
PRODUCER had never encoded the difference. Status-table rows for the three
controls, and the claim-refresh behaviour beside claim.ps1's entry.

WORKTREES.md: the announce id rule already warned that a prefix match
resolves a peer in the primary to an arbitrary worktree session, because
every worktree cwd extends the primary's. overlap.ps1 had that same trap
live at the same time. Noted there, with the distinction that matters:
overlap genuinely needs a prefix match, so the cure is longest-prefix
rather than exact-match.

And a correction. The broadcast-constraints list said of last week's merge
freeze that "#119 never merged (it died on an unrelated CI timeout)". It
merged the following day, 2026-08-02 01:45Z. Verified against the API
rather than restated. The lesson is unchanged and in fact sharper: the
recipients could not evaluate the predicate, so the freeze outlived its own
condition in both directions -- five sessions held while it had not
arrived, and a claim note was still announcing it hours after it had.

* docs(coord): announce-on-join merged and was never installed

Found while checking a peer session's report, not by looking for it. That
session announced itself by hand on 2026-08-02 and gave the reason as "the
hook is on an unmerged branch". It had merged (#133, 3389aa2) hours
earlier, so the observation was right and the diagnosis was not, and
nothing would have corrected it.

Measured across all five config roots:

  - no `mefor-announce` UserPromptSubmit entry anywhere
  - the one UserPromptSubmit entry installed is `# mefor-web-announce`,
    which resolves scripts/hooks/announce.ps1 -- a different script in a
    different repo, and one the installer's own comment already warns is
    easy to confuse with this marker
  - <git-common-dir>/mefor-coord/announce/ does not exist, so there is not
    a single receipt: it has never executed

install-coordination.ps1 was last run before the announce row existed, and
merging a hook does not install one. Its two other entries -- the
SessionStart banner and the collision gate -- were wired then and are
present, which is precisely why nothing looked wrong.

The part worth carrying: the missing-script notice was built so this class
could not hide, and it CANNOT FIRE when the hook is not wired at all,
because it lives inside the shim. Same shape as the defect this document
already records one level down -- the detector sat downstream of the
failure it existed to detect. So the status table now distinguishes rule
4's inert-BY-DESIGN from this one's inert-BY-ACCIDENT, and the confirmation
step is a receipt on disk rather than a reading of the settings file.

Not installed here: that writes ~/.claude/settings.json, which is shared
with every session on this machine. Owner's call, from a plain terminal.

* fix(coord): five defects this PR's own first pass introduced or left

Found by an adversarial review of the preceding commits, then each one
reproduced by execution before being touched. Two were regressions I had
introduced; three were gaps.

1. THE CLAIM FILE'S EXISTENCE IS THE LOCK, and the refresh unlinked it.
   `Move-Item -Force` is delete-then-rename. The take path is an exclusive
   CreateNew, so any instant the name does not exist is an instant another
   worktree can claim a key we hold -- i.e. the note refresh could hand a
   claim away. Measured on this box: 400 moves left the destination absent
   on 2,559 of 154,506 polls. [IO.File]::Move with overwrite is
   MoveFileEx(MOVEFILE_REPLACE_EXISTING), and the same harness never once
   saw the name missing across 134,581 polls. It fails transiently instead
   (13.5% under back-to-back churn, nothing like one refresh per run), so
   it retries five times and then reports; failing is the safe direction --
   the old note survives and the claim stays ours.

   The catch around it is deliberately UNTYPED: PowerShell wraps a .NET
   method's exception in a MethodInvocationException, so the typed catch I
   wrote first never matched, the failure escaped to ErrorActionPreference
   = Stop, and the temp file was orphaned in the claim registry. The
   orphaned-temp assertion is what caught it.

2. `overlap.ps1 -Json` emitted `[null]` for an empty map. Build-Map returns
   AutomationNull, which PARAMETER BINDING converts to a real $null at the
   call -- and `@($null).Count` is 1, so the zero-rows guard was dead in
   exactly the case it was added for and the whole-map query printed a
   phantom row. Strictly worse than the nothing it replaced. The -File path
   I had verified by hand was fine; the two call sites do not fail alike.

3. The unresolved-notice throttle was repo-wide. The stamp lives in the
   SHARED git-common-dir and production invokes the gate with no arguments,
   so the first session to hit a broken gate silenced it for every other
   session -- and those sessions read that silence as "checked, nobody is
   here", which is the precise defect the notice exists to remove. One
   session's diagnostic must never become another's false all-clear. Keyed
   per worktree now.

4. An empty payload or a literal `null` on stdin does not throw, so that
   was the one unreadable-input path still exiting silently.

5. A ghost session could outrank a live one. UNVERIFIED is the shape a
   crashed session's record takes once its pid is recycled; last-write-wins
   had no opinion about which record it kept for a directory, so a ghost
   could supply the id and branch reported for a worktree somebody is
   really sitting in. Fenced records now win, then sorted cwd.

Each fix is pinned, and the two regressions were checked against the
unfixed code: the phantom-row test sees `[null]`, and the claim test
asserts the file name never disappears while a refresh is failing.

* docs(worktrees): "is it live yet" has two answers, and they are different

I broadcast a merged claim.ps1 improvement to seven sessions as something
they could use immediately. A peer tried it, got the old behaviour, and
measured why: claim.ps1 is invoked BY HAND from the session's own worktree,
so it runs that worktree's copy, and their branch predated the change. The
in-force check I had given them was for the hook-run path and returned 0
for them.

Both halves of what I said were individually true. The combination was
wrong, because there are two rules and I collapsed them into one:

  hook-run   (collision_gate.ps1, and overlap.ps1 as its callee) -- the
             installed shim resolves the PRIMARY first, so it is live when
             the primary advances, whatever any branch contains
  hand-run   (claim.ps1, overlap.ps1, presence.ps1) -- resolved from the
             session's OWN tree, so it is live when that branch has it,
             and the primary is irrelevant

Tabulated, with the check spelled out per path. The point generalises past
this PR: test the property where the script will actually run from, because
a token that resolves in the primary says nothing about a hand-run script.

Also surfaces `collision_gate.ps1 -PathOverride <path>` as the read-only
"who holds this file right now" query. It is documented in-script only as a
test affordance, and the peer above found it by reading the source after it
answered a question nothing else would.

Both points are theirs, not mine.

* docs(worktrees): the freeze bullet had the right lesson and the wrong reason

Routed here by the ADR 0154 session because I was the one live in this
file. I had already corrected the false half -- "#119 never merged" -- but
only to "it merged the following day", and their framing is better, so this
takes theirs.

The failure was never that the condition could not arrive. #119 merged
(2026-08-02 01:45:00Z, 002be18). It is that THE WORLD MOVED WHILE EVERYONE
WAITED: main advanced four times first -- #74 20:27:03Z, #120 23:59:43Z,
#131 00:35:29Z, #130 01:01:35Z. So the freeze did not hold main still even
while nominally in force. It held only the sessions honouring it, which is
the worst of both, and it is a sharper argument for the same bullet without
resting on a false fact.

Every timestamp re-verified against the API here rather than restated; the
measurements are theirs. The same framing was independently corrected in
ci.yml (07b6e55) and in BACKLOG #340, making this the third document to
carry it and the last one live.

Also names what the bullet had become: a compensating control resting on a
false premise, which is the failure CLAUDE.md §11 lists -- occurring inside
the document that argues for the rule. That is worth one sentence, because
the next stale premise will look just as settled as this one did.

* docs(worktrees): put the two numbers behind the freeze bullet, with their sources

I omitted both for want of a source; the ADR 0154 session found both and I
re-ran each before taking it.

  12h15m  #119's auto-merge armed 2026-08-01 13:29:37Z, merged 01:45:00Z.
          The timeline event is `auto_squash_enabled` -- a filter on
          `auto_merge_enabled` returns nothing, which is why the wait looked
          unmeasurable. Recorded in the doc, since the next person to look
          will reach for the wrong event name too.
  8m26s   the claim declaring the freeze is stamped 2026-08-01 23:51:17Z;
          #120 merged 23:59:43Z.

The second is hedged in the doc, and their caveat was the right one: `claimed`
records when the KEY was taken, not when the NOTE was written. What tightens
it is that `refreshed` is ABSENT on that claim -- and on the code of the day
there was no way to edit a note in place at all, so the two coincide unless
someone hand-edited the JSON. Stated as "the claim was taken at", which is
what the argument needs and no more.

That claim is still on the board, still announcing the freeze, which is why
it is cited in the present tense.

* docs(ledger): the CI backstop does not re-check ownership, and said it did

Found while unblocking another session that could not commit a rescued ADR:
its number is allocated to a worktree that is not theirs.

LEDGER-GATE.md §3 said "CI re-runs the same rules with --ci", and Limits
said the --ci leg "is the backstop, and it cannot be bypassed from a
branch". Both are true of every rule except the one a reader is most likely
to be relying on. ledger_check.py:196 and :241 are each guarded by
`not self.ci`, so "was this number allocated to you" runs LOCALLY AND NEVER
IN CI.

It has to be that way, and the reason is worth keeping: owns() reads the
allocation store from <git-common-dir>/mefor-coord/alloc, and a CI runner
clones fresh with no store, so the check would return False for every ADR
and no ADR could ever merge. This is not a bug to fix. It is a limit that
was documented as its own opposite.

The consequence is now stated rather than left as an inference: a green CI
on an ADR or BACKLOG PR is NOT evidence the number was allocated to anyone.
And the residual is bounded in both directions -- after --no-verify a number
belonging to another session's unmerged branch can be committed with nothing
objecting, but the collision rule still blocks whichever of the two merges
second. Late, loud and recoverable, rather than silent, which is the
property the gate was actually built for.

Same defect class as the freeze bullet corrected two commits ago, and as the
collision gate this PR started with: a compensating control resting on a
false premise -- CLAUDE.md §11 -- this time inside the document describing
the control.

* feat(coord): publish the account's plan limits so a session knows before it is cut off

Sessions were hitting the plan limit mid-task and losing work. The real
quota state exists -- Settings > Usage shows it -- but nothing inside a
session could see it.

WHERE THE NUMBERS COME FROM, because it determines the whole shape. Claude
Code hands `rate_limits` to a statusLine command's stdin and NOWHERE ELSE;
the hook payloads were enumerated in the shipped binary and it appears in
exactly one of them. Quota state therefore cannot be subscribed to. It has
to be collected by a statusLine and published somewhere shared, which is
why this is scripts/coord/usage-collect.ps1 and not a hook.

ONE PUBLISHER, N READERS. The quota is account-wide, so any one session's
reading is true for all of them. The publish path is user-level because the
data is a property of the ACCOUNT, not of a checkout. Summing across
sessions would double-count one shared pool.

Three defects found by testing rather than by reading, each now pinned:

  - AN EMPTY READING CLOBBERED A GOOD ONE. Every session runs the
    statusLine, so every session is a publisher; one that has not yet had
    its first API response carries no rate_limits and blanked the account's
    only reading for all of them. Windows are absent INDEPENDENTLY per the
    docs, so the carry-forward is per window and keeps each window's own
    captured_at -- a stale number must not wear a fresh timestamp.
  - HISTORY MUST RECORD ONLY FRESH OBSERVATIONS. A carried-forward
    percentage against a new timestamp tells the burn rate that
    consumption stopped, which is the one lie that matters here.
  - RATE MUST NOT SPAN A WINDOW RESET. The percentage legitimately
    collapses at the boundary; a rate across it is large and NEGATIVE.
    Mutation-checked: removing the epoch filter yields -101.63 %/hr at the
    exact moment a fresh window starts being spent.

And a fourth, which is the same ConvertFrom-Json date coercion that
downgraded the stamp in claim.ps1: captured_at arrives already typed as a
[datetime]. Stringifying it drops the 'Z', re-parsing assumes local, and a
reading taken 90 seconds earlier reported as 299 minutes IN THE FUTURE --
exactly this machine's UTC offset. The sign is what made it dangerous: a
negative age passes an `age -gt max` test unconditionally, so the staleness
guard would have been disarmed on every non-UTC machine while still looking
present. Bounded both ways now.

WHAT IT CANNOT SEE, printed on every run rather than buried: the per-model
weekly buckets (Fable/Opus/Sonnet) and the plan tier are not in the payload
at all, and the request to expose them was closed as not-planned. If Opus
is burned hard across many sessions, the bucket most likely to stop you is
the one this cannot report. Two green bars and an invisible third is worse
than no tool.

Exit codes 0/10/11/20 so a coordinator branches without parsing prose.
UNKNOWN is a real answer and is returned for stale, undateable or
future-dated readings; nothing is ever extrapolated from a dead publisher,
and the statusLine does not run headless, so a dead publisher is the
expected steady state for the coordinator itself.

Not built on ccusage: it measures tokens and dollars, not plan limits,
despite being the tool everyone recommends and several summaries claiming
otherwise. Its own docs contradict them.

Not installed here -- it writes user-level settings shared by every session
on the machine, so that stays the owner's call from a plain terminal.
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