ADR 0156 — ASVS scorecard as data: derived count, verified evidence anchors, fail-closed drift gate - #120
Merged
Merged
Conversation
…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
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
disabled auto-merge
August 1, 2026 20:36
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The ASVS score is maintained as prose and nothing checks it. One re-anchoring session on 2026-08-01 produced, in a single day:
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.unverifiedis a first-class verdictOn 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/mypyall 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