fix(governance): the register's only usable write path was unguarded - #2879
Conversation
The collision gate is advisory on the only write path agents can use, and 41 of 404 register rows are postdated. One lane, both defects. Filed through the shell because this body has no Write/Edit tool -- the fourth delivery agent this session in that condition, and the reason the sanctioned write path in this lane has to ship before any deny does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz
The collision gate is wired to Write, Edit AND Bash, but the paths were not
equivalent. Measured with identical content against a lane COWORK-CLAUDE
genuinely holds: Write exited 2 and blocked, Bash exited 0 with an "ask" --
and an "ask" is resolved by a classifier reading the shell command, not the
lane, to which `cat >> REG <<EOF` looks entirely benign. The verdict was never
computed on that path at all.
That gap was load-bearing. Four delivery agents in one session had no Write or
Edit tool ("Write is disabled for this session, in subagents as well as here"),
so the advisory path was the only path any of them could file through.
The hook's docstring said recovering "what will this write" from arbitrary
shell is not something to attempt here. True of arbitrary shell; false of the
case every agent uses, because a heredoc body is already in the payload. So:
recoverable content -> the SAME evaluate_claims() the Write path runs,
exit 2, byte-identical message
not recoverable -> could not measure, which is NOT a pass -- refused,
naming the sanctioned path
The verdict now lives in one function both matchers call, so the two paths
cannot drift apart again.
DENYING WITHOUT AN ALTERNATIVE WOULD DEADLOCK THE FLEET, so this ships with
one: `make -C pmoves register-claim` / `register-release` / `register-docs`
append through validated code -- clock-read timestamp, the gate's own collision
check imported rather than reimplemented, O_APPEND. Every refusal names it.
Also here:
* A postdate ratchet. row_timestamp <= commit_author_time has no
false-positive mode: a row cannot record a moment that had not happened when
it was written. Sweep of main: 45 rows postdated in every commit that added
them, worst +5h05m; 368 of 412 rows (89.3%) carry `:00` seconds, so these
were typed, not read. Existing rows are REPORTED, not rewritten -- they are
other nodes' provenance. Wired as a ratchet beside validate-*-ratchet.
* Precision fix the deny required: _writes_the_register() fired on MENTION plus
any write token, so writing an unrelated file whose content named the
register raised an advisory listing the fleet's open lanes. Harmless as an
ask, unacceptable as a deny. Verdicts key on the write TARGET now.
Three bugs found by probing the gates, none visible by reading them:
`echo "$ROW" >> REG` exited 0 (an unexpanded variable treated as content);
a heredoc explaining "use cp to back up the register" was refused (the same
literal-match-inside-heredoc bug damage-control has, reproduced one layer
down); and `grep -c CLAIM REG` -- a plain READ -- was denied, which would have
made the register unreadable from the shell.
Tests 66/66. Gate matrix 22/22, every deny observed refusing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz
|
Important Review skippedAuto reviews are limited based on label configuration. 🏷️ Required labels (at least one) (1)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Filing the RELEASE row through the Known Road this PR adds is what found it. The recipe interpolated SCOPE into a double-quoted shell word, so every backtick in the row -- and a register row is MADE of backticks: timestamps, owner IDs, lanes, TTLs -- became a command substitution. The row came back with its code spans executed and deleted. Scope and co-owners now travel by ENVIRONMENT via target-specific export, so make never builds a shell word out of prose. Backticks survive verbatim, verified. Two residual sharp edges, documented rather than hidden: * make expands `$(...)` and `$X` in its own variables before anything else sees them, so `$(whoami)` in a SCOPE vanishes and `$HOME` becomes OME. There is no fixing that inside make; the register doc now carries the direct-invocation escape for prose containing `$`. * `--scope-file` added, and it is the option to reach for on long rows: the prose never becomes part of a command string at all. Filing this PR's own RELEASE hit the damage-control hook, which matches literal strings anywhere in a command INCLUDING inside prose -- a row quoting an ordinary path fragment was refused. Reading from a file sidesteps the whole class honestly, without touching that hook. The RELEASE row for this lane was then filed through the sanctioned path end-to-end: collision-checked, clock-read timestamp, 13 insertions and 0 deletions, code spans intact. Tests 66/66. Gate matrix 22/22. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz
…y trail Two nodes filing rows on two branches conflicted on the last line every time, and the resolution is always the same: keep both rows. Doing it by hand invites `--ours`/`--theirs`, either of which silently drops another node's provenance row -- and the collision gate this branch adds refuses the shell writes a manual fixup would need, so the conflict had no sanctioned resolution at all. known-roads.jsonl already carries merge=union for exactly this reason. The register, which is the most append-only file in the repo, did not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 772fdcc7b7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Closes the two P1s an independent review filed against this branch. P1-1 -- inline-interpreter writes bypassed the gate entirely. PY_WRITE_RE matched the command text but was only ever applied inside heredoc bodies; the shell skeleton was never asked the same question. python3 -c, node -e, ruby -e and ed all appended to, or truncated, the append-only ledger at exit 0. The shell path is now an ALLOWLIST rather than a longer denylist: a command segment naming the register must be positively recognised as a read, or it is could-not-measure and refused. What is allowed is enumerated in the source -- read-only commands, sed/find/jq with a guard for each one's own write flag, git read subcommands (not checkout/restore/switch/stash/apply/reset/clean), copy verbs with a directionality rule, and the sanctioned tools by name. Escape hatch for interpreters: pipe the register in, do not name it. P1-2 -- the "#2858 for free" claim was false. ad63bb8 is not an ancestor of this branch and its gate exports no evaluate_claims at all, so the sanctioned path crashed with AttributeError at EXIT=1 -- and exit 1 in this tool's own doctrine means "refused: lane held by another owner". A crash was indistinguishable from a legitimate refusal. evaluate_claims now returns a named ClaimVerdict carrying collisions, shared, one_sided, unkeyed and unreadable_co_owners. _apply_verdict is called by BOTH matchers, so the shell path emits SHARED LANE on stderr and permissionDecision: "ask" on stdout, byte-identical to the Write path. register_append.py is wrapped by _guarded(): an unexpected exception exits 3 (could not measure), never 1. Also: P2-1 lanes and co-owners read per row via _row_at, not per payload (P3-2's duplicate collision lines go with it); P2-2 redirect targets compared by basename, not substring, so .bak/.orig/.rej/.BACKUP are no longer refused; P2-3 directionality -- the register may be a copy SOURCE but never a destination, and git checkout --ours is now refused; P2-4 closed with make -C pmoves register-amend, a sanctioned path to add co-owners to your own open row. P3-1 reduced, not closed: two nodes amending the same row on two branches still union to two rows. Register auto-merged under merge=union: base 461, ours 463, theirs 470, merged 472. 0 rows lost from either side, 0 invented, 0 conflict markers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz
The allowlist closed the interpreter class and then re-made the same mistake one
layer in. `sort`, `shuf` and `xxd` were admitted wholesale to
`_READ_ONLY_COMMANDS`, and every one of them takes an OUTPUT file. Measured
against the allowlist cut, all EXIT=0 with the register as the destination:
sort -o <register> in.txt <- REPLACES the append-only ledger
sort --output=<register> in.txt
shuf -o <register> in.txt
xxd -r dump.hex <register> <- rewrites it from a dump
csplit -f <register> in.txt 2 <- names it as the output prefix
split -l 1 in.txt <register>
None of these is in the review, the 22-case matrix, or the 32-probe sweep that
replaced it. They were not found by thinking of more shapes -- they were found
by asking the allowlist the question it asks of everything else: is this command
CERTIFIED not to write? "Usually a read" is not "certified", and an allowlist
whose entries are command NAMES rather than command BEHAVIOURS is a denylist
wearing the other hat.
Guarded the way `sed -i`, `jq -i` and `find -exec` already are: the output flag
present at all is could-not-measure, and could-not-measure is not a pass. The
guard only ever runs on a segment that names the register.
The false-refusal direction is tested as hard as the hole direction, and it
caught a bug in the first cut: counting `100` in `split -l 100 <register>
/tmp/part-` as an operand made a plain read look like a write. Option values are
now skipped, so `sort <register>`, `sort -u`, `shuf -n 3`, `xxd`, `od -c`,
`split ... <register> /tmp/part-` and `csplit -f /tmp/pre- <register>` all still
read at exit 0 with no prompt.
19 tests added. At 39fa4b6 exactly the 10 write shapes fail and the 9 reads
pass; at f4238b0 the suite goes 48 -> 58 failures. Full suite 154 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz
Found by driving `register-amend` against a copy of the LIVE register instead
of a fixture. Two defects, both the same shape as the bug this whole lane
exists to remove -- a literal matched anywhere in the text, including inside
prose that merely QUOTES the register's own grammar.
MEASURED before, on a copy of the live register:
amend --owner 'B850-CLAUDE (Knuckles)' --branch chore/cli-prereq-preflight
EXIT=0 "amended line 2657 ... one row changed by insertion only"
The row it amended holds `feat/register-co-owner-attribution` and only CITES
that branch, inside a double-backticked example. The row that really held the
lane was already released, so the citing row was the last "open claim" left.
Then the insertion landed in the middle of the sentence "**Deliverable:** a
`co-owners:` field", because the extend-an-existing-field branch matched the
prose occurrence. So the real field was never set, the prose was corrupted, and
the tool reported success.
The purity check could not catch either one: inserting in the wrong place is
still an insertion, and the row count does not move.
* TARGETING now ignores ``quoted examples``. Scoped by code span rather than
by position, because the row grammar is not uniform -- hand-filed rows put
`scope:` BEFORE `branch:`, so "everything up to `scope:`" would refuse the
very rows an incumbent most needs to amend. That cut broke
test_amend_handles_the_grammar_the_register_actually_uses, which is the
test written for exactly that population.
* EXTENDING an existing `co-owners:` now looks only at the header, so a
sentence about the field cannot be mistaken for the field.
COLLISION DETECTION IS DELIBERATELY UNCHANGED and still reads the whole row. A
cited branch there causes an over-block, which is safe; narrowing it would
trade a false refusal for a missed collision. Named as a known over-block
rather than fixed under cover of an amend patch.
Also: a row whose timestamp is not an ISO date is accepted by `open_claims_in`
and rejected by `_ledger_rows`. That gap reached `before.index(row)` and threw.
`_guarded` made it exit 3 either way, but a sanctioned road that prints a
traceback teaches the reader the road is broken rather than the input.
4 tests added; all 4 fail at 6cb588a. Full suite 158 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz
Filed through the sanctioned road (`register_append.py claim --scope-file`), clock-read, collision-checked, O_APPEND. 472 -> 473 rows. The road refused this row on its first attempt, correctly: the scope prose carried the co-owner field name as a literal, and the gate reads a ledger row's whole text, so it saw a declaration naming no parseable IDs and returned EXIT=3 NOT MEASURED rather than checking a shared lane it could not read. That is the over-block this PR names rather than fixes -- it errs closed, and narrowing it would trade a false refusal for a missed collision. Reworded, not worked around. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz
Five Codex threads on #2879. Three P1s, all reproduced at 776b429 before being fixed, all with regression tests that fail there and pass here. TOCTOU in the sanctioned path. Two filers both read a free lane, both pass evaluate_claims, and both append: one lane, two owners, produced by the tool whose whole job is one owner per lane. Measured across two processes, both exit 0, two rows. O_APPEND orders bytes; it does not order decisions, and the docstring claiming otherwise is corrected. register_lock() now holds the whole read-check-write under fcntl.flock with a deadline -- never a bare blocking flock, which would hang the fleet's only write path with no message. A lock that cannot be taken is exit 3, never exit 1: exit 1 asserts a fact about the register, and a transaction that never ran established no such fact. Wider than reported: insert_docs and amend_co_owners are read-modify-WRITE over the whole file, so a row appended between their read and their write is not duplicated, it is DESTROYED. Both take the lock too. Postdate check compared %aI. git commit --amend keeps the author date and moves the committer date, so an honest clock-read row folded into a commit read as POSTDATED by 1h30m -- a required gate manufacturing the exact defect it exists to detect. Now %cI. Not max(%aI, %cI): an author date is settable to anything, so max() accepts a row postdated to a future author date, measured reading clean at the old head and caught now. One historical false accusation drops out (53 -> 52 on --sweep); the PR range stays clean. A branchless CLAIM exited 0 on the shell path -- and on the Write path, which the thread did not say. The sanctioned path refuses the identical row at exit 3, so the raw shell write was the MOST permissive door in the system, and the road every refusal message points at was the strictest. Refused now on both matchers through the shared verdict, before the ask rather than after, so a payload carrying both cannot emit "ask" on stdout and then exit 2. Two suffix-filter tests were passing through the unkeyed path rather than the filter they exist to test; each row now names its own lane, which isolates the property. Makefile: 6 of 7 register field/target combinations executed a command substitution embedded in a field value. SCOPE already travelled by environment so the ROW was correct -- and the emptiness guard still expanded it, so the command ran anyway. A correct row is not the whole test. Every field travels by environment now and the guards test the exported variable. REGISTER_PYTHON probes for PyYAML and only falls back to uv run --script if the chosen interpreter lacks it; switching unconditionally would trade a silent degradation for an offline network fetch. register_append.py declares its own PEP 723 dependency, because uv reads the block of the script it is pointed at and never the hook's. And the degradation now says what it costs: a RELEASE under another spelling of your own ID will not close your CLAIM. 177 passed (123 hook + 44 append + 10 postdate). The 32-probe allowlist matrix is encoded in that suite and re-driven in full: 0 defects. At 776b429 the ten new tests are exactly the delta (18 failed there vs 8 pre-existing unrelated failures here). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz
…its message
Tier 2 smoke carried its own assertion that an unkeyed CLAIM exits 0 -- the
contract the previous commit changed. I grepped the test files and missed this
one, and CI caught it. Updated to BLOCK, with the reason recorded in place.
The replacement assertions then failed for a different reason worth keeping:
`set -o pipefail` is on and the hook now exits 2, so `hook | grep -q` returns 2
however well the grep matched. The stderr checks would have reported a missing
message that was right there in the output. Both now capture into a variable
first, which is immune to the hook's exit code.
bash .claude/hooks/test/run_smoke.sh
Smoke validation: PASS=30 FAIL=0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz
Only the register conflicted. Resolved as UNION and never by taking a side: it is append-only and both sides were appending, so picking one deletes another node's claim record. main's row kept contiguous and first, this lane's CLAIM appended after — the precedent this same file records at the LANE-ATTRIBUTION-CI-UNBLOCK row. Proof the resolution deleted nothing: 5 insertions / 0 deletions against origin/main, 120 insertions / 0 deletions against the pre-merge head. Insertions, zero deletions, both refs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz
…ted them, then deleted them (#2886) * chore(register): CLAIM the COMPOSE-INTERPOLATION-BLOCK lane Four unset Supabase variables fail compose's ${VAR:?} gate, and compose interpolates the entire project before acting — so they block `up -d cipher-api` and every other unrelated service on this node. Runs in an isolated worktree and appends only: lane #2879 owns this file's write path and the main working tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz * fix(secrets): the funnel declared these four secrets required, then never generated them No container in the main compose project could be recreated on this node. `make up-cipher` failed before touching a container: error while interpolating services.supabase-pooler.environment.[]: required variable SECRET_KEY_BASE is missing a value required variable VAULT_ENC_KEY is missing a value error while interpolating services.supabase-analytics.environment.[]: required variable LOGFLARE_PUBLIC_ACCESS_TOKEN is missing a value required variable LOGFLARE_PRIVATE_ACCESS_TOKEN is missing a value Compose interpolates the ENTIRE project before acting, so four unset Supabase variables blocked `up -d cipher-api`, a completely unrelated service — and with it every other service on the node. The declarations were never the problem. All four are already `required: true` in both CHIT manifests AND in bootstrap/registry.json with correct, vendor-matching generators (SECRET_KEY_BASE 64 chars per supabase docker/CONFIG.md; VAULT_ENC_KEY 32-char hex for the AES.GCM Cloak vault; LOGFLARE tokens 32). scripts/supabase/generate-keys.sh independently agrees. What was missing was EXECUTION: no step of `make secrets-funnel` ever ran that generator. The full chain, and why it stayed invisible: * registry.json pointed these four at a GENERATED tier file. The funnel owns and rewrites that file from the CGP bundle, so anything written straight into it is discarded on the next run. 105 of 142 registry entries correctly target the funnel SOURCE instead. * chit-export encodes only the funnel source into the CGP bundle, so the four never entered it. * secrets_sync then classified them operator-missing. SECRETS_ALLOW_MISSING defaults to 1, so that was a WARNING and the funnel exited 0. * worse, build_outputs puts a missing REQUIRED entry into rejected_out, and write_env_files DELETES rejected keys in merge mode. So each funnel run actively removed the keys compose needs. Fix: retarget the four to the funnel source, and add a mint step that runs before chit-export so a fresh value reaches the CGP bundle in the same pass. `ensure_secret` fills ONLY absent-or-empty slots. It deliberately does not reuse bootstrap()'s regenerate-on-format-failure self-heal: that path rewrites a random_hex slot, and VAULT_ENC_KEY is read with bytes.fromhex() by the yt OAuth vault, so an implicit reshape destroys stored OAuth cookies. A node that already holds these values is untouched — verified live, the funnel reports "already set — left untouched". SECRETS_ENSURE_KEYS is curated, not "every key with a generator": ANON_KEY/SERVICE_ROLE_KEY are HS256 JWTs derived from JWT_SECRET, and POSTGRES_PASSWORD is minted at db-init and must match the running database. Minting random values for those would satisfy the `:?` gate with material that is wrong at runtime. No compose file is modified (read-only on this node) and no `:?` gate is weakened — the gate was right; the provisioning was missing. secrets-ensure-check makes the unprovisioned state fail loudly instead of warning, scoped to these keys rather than promoting all 8 existing funnel warnings to errors. Measured, controlled A/B in an isolated worktree (CHIT bundle redirected to scratch; no container touched): CONTROL (ensure disabled): funnel exit 0, four in "Missing secrets", tier file 2 entries, compose config exit 1 with exactly the four errors above — reproduces the node failure. TREATMENT (ensure enabled): four minted, absent from "Missing secrets", tier file 6 entries, compose config exit 0. The control also caught a bug in this change: an empty SECRETS_ENSURE_KEYS fell through to the full INTERACTIVE bootstrap and died on EOFError under make. Guarded, with a test. Tests: 10 new, covering the negative safety property (never overwrite, never reshape a malformed value), idempotence, hex shape for the bytes.fromhex() consumer, and the registry contract. Full suite unchanged vs baseline on the same tree: 235 failed / 141 errors both before and after, 2212 -> 2222 passed (exactly the 10 added). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz * fix(secrets): the funnel minted over live keys, and a PR body cannot guard a step that fires by itself secrets-ensure-generated is unconditional and runs inside `make secrets-funnel`. On a node where env.shared has lost one of these values while a running container still holds it -- the recovery state this PR exists to serve -- minting is not a bootstrap. It replaces live cryptographic material, and secrets-funnel-sync materializes the replacement into the tier files on the very next line. A later supabase-pooler recreation then gets a different VAULT_ENC_KEY and cannot decrypt the tenant credentials, or the YouTube OAuth cookies, already stored under the old one. secrets-runtime-hydrate runs earlier and does recover container values, but only Supabase aliases plus Meili, Firefly, Agent Zero and Invidious. It never looks at these four, so the mint is reached with the slots still empty. Telling the operator in the PR body to harvest first is not a fix: the step fires automatically. The guard has to be in front of the mint. The branch is NOT "is this node new?". It is "does any running container or persisted volume hold state encrypted under the old value?" holder present -> harvest it, never mint over it no holder, state volume there -> REFUSE, exit 3, write nothing neither -> defer to the existing mint, unchanged Joined with `&&` rather than `;` so a refusal STOPS the funnel instead of being a warning the next line ignores. It lives at secrets-ensure-generated rather than one target upstream because that target is itself public: a guard at secrets-runtime-hydrate would leave `make secrets-ensure-generated` destructive. Two more refusals the review did not reach. Holders that DISAGREE: no ack unlocks that, because "pick one of two live values for me" is a different decision from "there is state I cannot reach". And a slot that is already set is never touched even when it disagrees with the fleet -- this fills empty slots, it is not a reconciler. A harvested value that fails its registry shape is harvested ANYWAY, with a warning. Refusing on shape would brick recovery on exactly the node that needs it: the 4090's VAULT_ENC_KEY was minted urlsafe into a random_hex slot and the whole fleet agrees on that broken value. Desync is the larger hazard than malformed-but-agreed. supabase-db-data as the state volume is measured, not assumed: pooler DATABASE_URL and analytics POSTGRES_BACKEND_URL both resolve to supabase-db:5432/_supabase, whose datadir is that volume, and yt-cookie-refresher/supabase_client.py stores the Fernet ciphertext in Supabase too. yt-cookies-vol holds only decrypted output, so it is correctly not treated as state. The escape hatch names keys and never accepts a blanket switch: parse_ack("1") and parse_ack("true") both return the empty set, because a blanket flag is the one that gets pasted from a stale runbook onto a node nobody reasoned about. Measured against the live fleet from the same recovery state: the mint step alone leaves 4/4 keys desynced from the running containers; guard-then-mint leaves 0/4 and the mint reports "already set" four times. State 3 driven against this node's real 39 volumes refuses at exit 3 having written nothing. 66 tests pass across the five secrets modules, 25 new; the wiring tests fail 3-of-4 against the pre-change recipe, the one pass being a preservation test that must hold on both sides. bootstrap_env.py is deliberately UNMODIFIED. PR #2881 already fixes the --length default=48 defect, its mis-attributed provenance line and the "(generated None)" sibling, at the same lines; a second fix would only conflict. This guard imports rotate_secret / read_env_value / value_matches_spec from it rather than reimplementing them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz * fix(tests): 64 was this branch's expectation, not Supabase's requirement `python-tests` went red on two assertions this branch added, both against a registry value #2881 changed after the branch was cut: FAIL test_mints_when_key_is_present_but_empty assert 96 == 64 FAIL test_the_four_blocking_keys_are_generatable... length 96 != 64 Which number is right, from the vendor and the consumer rather than from whichever makes the test pass: * PMOVES-supabase/docker/CONFIG.md:926 says "recommended length: 64 characters" -- a recommendation. * Phoenix rejects below it at boot ("cookie store expects conn.secret_key_base to be at least 64 bytes") -- a FLOOR. * Neither states an upper bound. Units, since the two specs are in different ones elsewhere in this file: `random_urlsafe` is `secrets.token_urlsafe(n)[:n]`, so a declared length is output CHARACTERS, and that alphabet is ASCII, so 96 characters is 96 bytes and clears the 64-byte floor with margin where 64 cleared it with none. (Contrast VAULT_ENC_KEY: `random_hex` length is also characters, but the yt OAuth vault feeds it to bytes.fromhex(), so its 32 chars are 16 bytes. Untouched here.) So 96 is correct and the expectation was the stale thing. #2881 raised the registry deliberately, verified live that supabase-realtime went healthy at 96, and moved the help text to `openssl rand -base64 72` so the manual and generated paths agree; pinning 64 here would have silently reverted that. The mint test now asserts against the registry-declared length AND separately `>= 64`, so a deliberate bump is no longer a failure but a bump below what Supabase accepts still fails here instead of at container boot. The registry test keeps its literal pin -- noticing registry edits is its whole job -- and now tells the next reader to check the floor before editing it. Also pins the property CodeQL 388/389 asked about. Both alerts name bootstrap_env.py:89/93 (the generic _warn/_info printers) with the source given as the call expression `ensure_secret(...)` itself: that is the analyzer's name heuristic marking the return of a *secret*-named callable, so both halves of `generated, reason = ...` inherit the mark. Traced: ensure_secret has three returns, two string literals and one built from the registry's declared generator type and length, and rotate_secret's return -- the actual material -- is discarded, not assigned. Nothing carries a value to either sink. That makes it a false positive today, and nothing in the signature keeps it one; the obvious future edit is to add the value to the message to help someone debug, which would leak silently because the alert is already dismissed. Two tests now assert it instead: the primitive's `reason` never contains the minted value and emits nothing, and `main(--ensure ...)` driven over all four blocking keys prints the key NAMES and never their values. Both verified non-vacuous by mutation (interpolating the value into `reason` fails both). No suppression marker added: `# lgtm[...]` suppresses nothing and suppression-marker-check blocks it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz * chore(register): RELEASE the CI-GREEN-2886 lane Records which of 96 and 64 is correct and why, that the two conftest ImportErrors are baselined and unowned, the CodeQL 388/389 trace and the two mutation-verified tests that hold the property a dismissal would otherwise stop asking about, and the five harvest states re-driven against the node's real 39 volumes with the fleet unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…a refuted cause Register hygiene done through the sanctioned `make register-release` path. Open claims 24 -> 21; expired-and-never-released 5 -> 2. fix/pm-pick-python-empty-array #2809 MERGED fix/register-write-path-failclosed #2879 MERGED chore/cli-prereq-preflight #2761 CLOSED UNMERGED - gap still open fix/nats-bus-auth-outage delivered; CLAIM hypothesis refuted The nats row matters beyond bookkeeping. Its CLAIM asserted that rotate_secret replaces the first occurrence while readers take the last, making every rotation a silent no-op. That is false: NATS_PASSWORD occurs exactly once, and rotate_secret has dropped later duplicates since #1854. The real cause was an exported shell variable shadowing --env-file. Left unamended, an append-only register would have preserved a fleet-wide credential-rotation scare as its own last word. Two lanes remain expired pending a disposition trace. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…XPIRED while returning 0 Two defects, both filed by review on #2894, both correct. 1. THE SANCTIONED-TOOL ALLOWANCE WAS A PASSWORD, NOT A CHECK. Adding `register_status.py` to the hook's suffix tuple made the filename a bypass token. `_segment_verdict()` approved the ENTIRE command when any argument anywhere in argv ended with that name -- before anything asked which program would run. Measured on the parent revision: python3 /tmp/register_status.py pmoves/docs/AGENTS/<register> -> exit 0 An arbitrary script, chosen by the caller, handed the append-only ledger as argv[1] with the gate's blessing. It can truncate it. #2879 made this path fail closed precisely because a shell hole let agents write the register unchecked; #2894 re-opened one in the shape of a trusted filename. `cp <tool> <register>` and a byte-identical copy outside the repo passed the same way. The allowance is now keyed on the RESOLVED FILE and the invocation SHAPE: the script operand must resolve -- the way the shell will resolve it, against the command's own cwd -- to the same real path as this repository's copy, reached either directly or as the script operand of a python/uv interpreter. The repo is located from the hook file itself, the one path in the command's environment the caller does not choose. A symlink to the real tool passes because it IS the real tool; a copy does not, because "same content today" is not "runs the gate's own code". Resolving cwd-relative and NOT falling back to the repo root is load-bearing: an agent standing in its own tree runs ITS `pmoves/tools/register_status.py`, and a hook that resolved that token against the repo would certify a program that is not the one about to run. `make` carried the identical defect one layer along -- the TARGET NAME alone was the test, so `make -C /tmp/evil register-status ARGS=<register>` ran an arbitrary Makefile under a sanctioned name. It now requires an explicit `-C` resolving to this repository's `pmoves`, and refuses `-f`. 2. AN EXPIRED SHARED LANE PRINTED THE FINDING AND RETURNED CLEAN. In branch mode the expiry check sat behind `not verdict.shared`, so a reciprocated lane whose open claim was already past its TTL rendered `TTL 24h EXPIRED 3d3h ago` and exited 0. The whole-file report exits 1 on that identical row. A report that PRINTS the finding and RETURNS a pass is the fail-open this tool exists to prevent -- automation reading the exit code would accept a stale co-held lane as clean -- and it contradicts the documented contract that an expired unreleased claim is a finding. Matching expired rows are now checked before success, the report says why in words, and the JSON `branch` object names them so a consumer need not re-derive from `open_claims` the one fact that moved the exit code. Shared-lane REPORTING is untouched: co-holding is deliberate and the register must keep naming everyone who worked a lane. What is not deliberate is a broken promise-to-release, reported in full, scored as a pass. EVIDENCE. Nine tests added, and proven non-vacuous by reverting each fix: against the parent behaviour all five P1 negative controls return exit 0 (the gate approving) and the shared+expired lane returns 0 while printing EXPIRED; restored, all pass. Register/hook suite 204/204. Full suite failure set is byte-identical to HEAD's -- 254 FAILED/ERROR lines both ways, zero regressions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…not a gate (#2894) * feat(register): a refusal that names only write paths is a dead end, not a gate #2879 closed the shell write-hole on the claim register correctly, and it should not be loosened: six interpreter shapes were reaching an append-only ledger at exit 0, and a command line cannot be trusted to declare its own intent, so the allowlist has to enumerate what it positively recognises as a read. The cost was that it left the question with no answer. `open_claims_in()` is what the register names as the authority on what is open, it is only reachable from an interpreter, and every interpreter naming the register is refused. The three sanctioned paths the refusal offered -- register-claim, register-release, register-amend -- are all writes. An agent could file a claim and could not ask whether the lane was free. Adds `make -C pmoves register-status`: open lanes and who holds them, `BRANCH=` for whether one lane is free, and which open claims carry a TTL and which of those have expired. It is not a second parser -- open_claims_in(), canonical_owner() and evaluate_claims() come from the hook, build_row() and _ttl_delta() from register_append, ROW_RE/parse_ts from register_postdate_check. Asserted as SET EQUALITY against the gate's own open_claims_in(), not matching totals: two counts can agree while the rows underneath disagree, and the row is what a claimant acts on. BRANCH= renders the row register-claim would file and puts it through evaluate_claims(), so the answer is the gate's own three-way verdict rather than a branch-string comparison that would drift from it. The probe is never appended; the register is asserted byte-identical across every mode. Two things the measurement surfaced and the target now reports: 3 open claims are expired and never released, and 15 of 21 carry no TTL at all, so they cannot expire and nothing will ever prompt a release. The tool refuses to answer (exit 3) when PyYAML is missing. Without it the gate compares owner IDs exactly; for a WRITE that fails closed -- it blocks more -- but for a READ the same degradation inverts and fails OPEN, reporting a lane held under one spelling as free to someone asking under another. Hence REGISTER_PYTHON on the target rather than $(PYTHON). Hook changes are the minimum that makes the road usable, and they were load -bearing: measured against origin/main's hook, BOTH new roads were refused at exit 2 -- the target existed and the gate it exists to satisfy would not let it run. `register-status` joins the make allowlist, `register_status.py` joins the sanctioned-tool names (still refused if `-c` rides along), and the opaque refusal now names the read path beside the write path. The write surface is unchanged: the full #2879 matrix re-driven, 60/60, with every interpreter write, deletion, replacement and output-flag shape still refused and every legitimate read still allowed. 195 tests pass across the hook and the three register tools. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkwiW3VY1xWmahTAtVioxz * docs(register): close four B850 lanes, and correct one that recorded a refuted cause Register hygiene done through the sanctioned `make register-release` path. Open claims 24 -> 21; expired-and-never-released 5 -> 2. fix/pm-pick-python-empty-array #2809 MERGED fix/register-write-path-failclosed #2879 MERGED chore/cli-prereq-preflight #2761 CLOSED UNMERGED - gap still open fix/nats-bus-auth-outage delivered; CLAIM hypothesis refuted The nats row matters beyond bookkeeping. Its CLAIM asserted that rotate_secret replaces the first occurrence while readers take the last, making every rotation a silent no-op. That is false: NATS_PASSWORD occurs exactly once, and rotate_secret has dropped later duplicates since #1854. The real cause was an exported shell variable shadowing --env-file. Left unamended, an append-only register would have preserved a fleet-wide credential-rotation scare as its own last word. Two lanes remain expired pending a disposition trace. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(register): close the last two expired B850 lanes with their real disposition Zero expired-and-never-released rows remain; open claims 24 -> 18. fix/branch-audit-protected-divergence NEVER-DELIVERED docs/hardened-branch-topology PARTIAL, and overrun Neither closes quietly. The first promised divergence+age on PROTECTED rows in branch_cleanup.py; `git log origin/main` on that file since the claim is empty and the blind spot is unchanged, so the RELEASE says so and hands the gap back rather than implying it was handled. The second is the sharper one. Its CLAIM said, verbatim, "characterisation only - no merge, rebase, retire, or push." The question was then answered and acted on by two differently-named branches in the same session, one of which (#2818) performed exactly the re-baseline this lane promised not to do. The register named one thing under one name while something larger happened under two others. That determination - hardened = old main + drift, 129/200 sampled files byte-identical to March-era main - survives only in a PR description. No file under pmoves/docs carries it, so a fresh clone does not have it. Logged as an open, unowned gap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(register): the gate trusted a FILENAME, and branch mode printed EXPIRED while returning 0 Two defects, both filed by review on #2894, both correct. 1. THE SANCTIONED-TOOL ALLOWANCE WAS A PASSWORD, NOT A CHECK. Adding `register_status.py` to the hook's suffix tuple made the filename a bypass token. `_segment_verdict()` approved the ENTIRE command when any argument anywhere in argv ended with that name -- before anything asked which program would run. Measured on the parent revision: python3 /tmp/register_status.py pmoves/docs/AGENTS/<register> -> exit 0 An arbitrary script, chosen by the caller, handed the append-only ledger as argv[1] with the gate's blessing. It can truncate it. #2879 made this path fail closed precisely because a shell hole let agents write the register unchecked; #2894 re-opened one in the shape of a trusted filename. `cp <tool> <register>` and a byte-identical copy outside the repo passed the same way. The allowance is now keyed on the RESOLVED FILE and the invocation SHAPE: the script operand must resolve -- the way the shell will resolve it, against the command's own cwd -- to the same real path as this repository's copy, reached either directly or as the script operand of a python/uv interpreter. The repo is located from the hook file itself, the one path in the command's environment the caller does not choose. A symlink to the real tool passes because it IS the real tool; a copy does not, because "same content today" is not "runs the gate's own code". Resolving cwd-relative and NOT falling back to the repo root is load-bearing: an agent standing in its own tree runs ITS `pmoves/tools/register_status.py`, and a hook that resolved that token against the repo would certify a program that is not the one about to run. `make` carried the identical defect one layer along -- the TARGET NAME alone was the test, so `make -C /tmp/evil register-status ARGS=<register>` ran an arbitrary Makefile under a sanctioned name. It now requires an explicit `-C` resolving to this repository's `pmoves`, and refuses `-f`. 2. AN EXPIRED SHARED LANE PRINTED THE FINDING AND RETURNED CLEAN. In branch mode the expiry check sat behind `not verdict.shared`, so a reciprocated lane whose open claim was already past its TTL rendered `TTL 24h EXPIRED 3d3h ago` and exited 0. The whole-file report exits 1 on that identical row. A report that PRINTS the finding and RETURNS a pass is the fail-open this tool exists to prevent -- automation reading the exit code would accept a stale co-held lane as clean -- and it contradicts the documented contract that an expired unreleased claim is a finding. Matching expired rows are now checked before success, the report says why in words, and the JSON `branch` object names them so a consumer need not re-derive from `open_claims` the one fact that moved the exit code. Shared-lane REPORTING is untouched: co-holding is deliberate and the register must keep naming everyone who worked a lane. What is not deliberate is a broken promise-to-release, reported in full, scored as a pass. EVIDENCE. Nine tests added, and proven non-vacuous by reverting each fix: against the parent behaviour all five P1 negative controls return exit 0 (the gate approving) and the shared+expired lane returns 0 while printing EXPIRED; restored, all pass. Register/hook suite 204/204. Full suite failure set is byte-identical to HEAD's -- 254 FAILED/ERROR lines both ways, zero regressions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * ci(register): the 27 tests proving the filename-bypass is closed ran on no CI machine The gate fix in 6a7a9f3 shipped with `test_register_status.py` as its whole proof and nothing anywhere collected the file. Not "the workflow did not fire" -- it fires. This PR touches `.claude/hooks/governance/claim-collision-pre.py`, which IS in the `paths:` trigger. The workflow runs, executes the two OLDER test files, and reports GREEN. That is the worst available failure mode: the proof is dark and the dashboard says the proof passed. Measured on the PR head, addopts neutralised so `-v` cannot mask `-q`: pytest -o addopts= --collect-only -q <workflow's OLD file list> \ | grep -c 'test_register_status.py::' -> 0 pytest -o addopts= --collect-only -q -> 0 (default testpaths) Both zero, from two directions. `pmoves/pyproject.toml` `testpaths` covers `pmoves/tests*` only, so `pmoves/tools/tests` is reachable ONLY by explicit enumeration -- and the new file was enumerated nowhere. Non-vacuity, because a wiring change that cannot fail is this same defect one level up. With one assertion in `test_register_status.py` deliberately broken: old file list -> 177 passed, exit 0 <- green, break undetected new file list -> 1 failed, exit 1 <- the step fails Restored byte-clean (`git diff` empty); new list then 204 passed, exit 0. `register_status.py` itself was also absent from `paths:`, so editing the tool under test triggered nothing. Added alongside its test, matching how `register_postdate_check.py` and `register_append.py` are each paired here. testpaths deliberately NOT widened. Adding `pmoves/tools/tests` would collect the whole directory into the default suite and turn it red on any node without optional ML deps -- measured, 8 failed / 439 passed / 3 skipped: 6 in test_cymatic.py (no `librosa`), 2 in test_beats_features.py (no `sklearn`). Trading a silent failure for a noisy one is not a fix, so the wiring is explicit and the comment says why the enumeration is load-bearing. This is a class, not an incident. Of 29 test files under `pmoves/tools/tests`, 8 are now reachable and 21 are collected by nothing -- 268 tests that run on no CI machine. Named as follow-up, deliberately not fixed here to keep this change reviewable. A defense whose proof runs only on its author's laptop is not a defense on any other node. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(anchors): drop 24 suppressions whose defects were fixed — a stale entry is a blind spot The anchor ratchet failed on this PR with ZERO new findings: 474 total, 474 baselined, 0 new. It failed only because 24 baseline entries name defects that no longer occur. That refusal is the point. A suppression is a promise that a specific defect is known and tolerated; once the defect is gone the entry stops describing reality and starts holding a door open — the same bad anchor can be reintroduced later and the gate will say nothing, because the entry still matches. The ratchet will not let a suppression outlive what it suppresses. All 24 were fixed by #2913 (merged 2026-09-03), by two mechanisms, verified one by one rather than sampled: - target now defined (10): health-agent-zero, a0-mcp-smoke, a0-mcp-exec-smoke were .PHONY-declared and documented but never defined; #2913 defined them (health-agent-zero now at pmoves/Makefile:3892). - doc reference removed (14): the e2b / wger / firefly-iii / jellyfin READMEs were reconciled against reality and stopped naming targets that never existed. Zero unexplained: no entry went stale because a file was deleted or fell out of the scan. INHERITED, NOT INTRODUCED BY THIS PR. #2894 touches six files — the claim hook, the postdate workflow, pmoves/Makefile, the register, register_status.py and its test — and none of the 24 doc anchors. Its only Makefile change adds the register-status target. This PR merely surfaced the staleness because pmoves/Makefile is a trigger path for the anchor workflow. Why the baseline was allowed to go stale is worth recording, because it is not that the signal was missing. #2913's own ratchet run printed the identical "474 total, 474 baselined, 0 new" and "STALE BASELINE - 24 entries no longer occur", concluded FAILURE, and the PR merged 2.5 hours later anyway — the anchor ratchet is not in the required-check set, so a red run does not block. The signal was delivered and passed over, and it then landed on the next PR to touch a trigger path. (The workflow also has no push trigger, so nothing re-checks main between PRs; that is why the debt sat rather than why it formed.) Direction of change verified before commit, since re-baselining is exactly the operation that can silently loosen a gate: the diff is 24 deletions and 0 insertions, and the removed set is an exact match for the 24 reported stale entries. Nothing new is suppressed. Post-change the ratchet still reports 474 total / 474 baselined / 0 new and now exits 0 — the gate is strictly tighter, and those 24 anchors are live checks again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The defect
claim-collision-pre.pyis wired to PreToolUse onWrite,EditandBash. The paths were not equivalent. Measured onmain@94224d955, identical content, againstfeat/hirag-mcp-bridge— a laneCOWORK-CLAUDEgenuinely holds at L1086:WriteBashpermissionDecision: "ask"The collision was never computed on the Bash path. The advisory listed open lanes and deferred to a permission classifier that evaluates the shell command, not the lane — and
cat >> REG <<EOFlooks entirely benign to one of those.This was load-bearing, not academic. Four delivery agents in this session had no
WriteorEdittool — verbatim:Write is disabled for this session, in subagents as well as here. The advisory path was the only path any of them could file through. The register's own2026-09-01T19:08:57Zrow already records the same condition, so it is at least two sessions old. The CLAIM row for this PR was appended through a shell heredoc and returned exit 0 with the gate silent.The docstring was half right
It said recovering "what will this write" from arbitrary shell "is not something to attempt in a PreToolUse hook."
True of arbitrary shell. False of the case every agent here actually uses: a heredoc body is in the command string the hook already receives.
echo/printfliteral) → the sameevaluate_claims()the Write path runs → exit 2 with a byte-identical message.sed -i,cp, a path or row in a variable, a heredoc fed to an interpreter) → could not measure, which per repo doctrine (0 clean · 1 findings · 3 could not measure — NOT a pass) is a refusal, naming the sanctioned path.The verdict now lives in one function both matchers call, so the paths cannot drift apart again.
The deadlock, and why the deny is safe
Denying shell writes while agents have no
Writetool would stop the fleet from claiming work at all — strictly worse than the gap. So the sanctioned path ships in the same change and every refusal names it:pmoves/tools/register_append.pyreads the clock (so a row cannot be postdated), imports the gate's own check rather than reimplementing it, refuses a CLAIM naming no branch (78 live rows are unenforceable for exactly that reason), and appends withO_APPEND.Postdate ratchet
row_timestamp <= commit_author_timehas no false-positive mode — a row cannot record a moment that had not happened when it was written.Sweep of
main: 45 rows postdated in every commit that added them (50 in at least one), worst +5h05m. 368 of 412 rows (89.3%) carry:00seconds — hand-rounded, not clock-read. (The lane brief said 41/404 and 89.4% viagit blame; my count is per-commit and includesREVIEW/UPDATE/HANDOFFkinds and rebase re-adds. The:00figure corroborates independently to within 0.1pp.)Existing rows are reported, never rewritten — they are other nodes' provenance.
make -C pmoves register-postdate-sweepreports them; the gate judges only rows a PR adds, so the count can go down and cannot go up. Same shape asvalidate-command-anchors/validate-composes/validate-dockerfile-paths.Proof every gate says no — 22/22
Every deny observed refusing, with exit codes:
Both paths produce identical stderr on collision — pinned by
test_the_shell_block_message_matches_the_write_path.Three bugs the probes found in my own code
None were visible by reading it:
echo "$ROW" >> REGexited 0 — the recovered "literal" was the string$ROW, an unexpanded variable treated as content. Quote type now decides: single quotes suppress expansion, double quotes do not./tmp/notes.mdwhose prose said "use cp to back up the register" was refused. That is the same bug damage-control has — matching literal strings inside heredoc content — reproduced one layer down. Fixed by splitting the shell skeleton from heredoc bodies and only asking code bodies about writes.grep -c CLAIM <register>— a plain read — was denied. That one case would have made the register unreadable from the shell.A fourth, in the sanctioned tool:
append_row(row, register=REGISTER)bound the default at import, so a redirectedREGISTERstill wrote to the original path — the first test run appended three junk rows to the live register (reverted, not committed).Interaction with #2858 (OPEN,
dirty)Cut from
main, not from #2858. #2858 adds co-owner semantics (reciprocated → allow +SHARED LANE; unilateral → ask; undeclared → block) to the Write/Edit path only.This PR factors that verdict into
evaluate_claims(), called by both matchers. Resolution recipe: put #2858's three-way logic insideevaluate_claims()and keep this PR's_gate_shell_write()— co-owner semantics then reach the shell path for free, which is what #2858's own docstring says it wants ("more than one node on a lane is the village working").Expected conflicts:
main()and the region where_advise_on_shell_writewas. Make targets are anchored ~30 lines from #2858's insertion point to avoid a Makefile conflict. Hook tests are in the existingpmoves/tests/test_claim_collision_hook.py, which #2858 also touches — resolve by union.Known limitation, stated not hidden
The gate cannot tell a prose edit from a row edit inside an opaque shell write, so shell edits to the register's own documentation are refused too.
register-docsmode is the remedy: it builds output as a pure insertion (deletion structurally impossible) and asserts ledger rows are byte-identical before and after —54 insertions, 0 deletions, 442 rows unchangedon this PR. It also refuses prose containing a row, so it cannot become a second unchecked way to file a claim.A command that never names the register is still out of scope — unchanged from
main.Tests
66/66—41hook (29pre-existing unchanged,12new/retargeted),8postdate,17sanctioned path.The 9 tests that previously asserted
"ask"on shell writes were retargeted, not deleted; each keeps its original intent and documents why the contract changed.🤖 Generated with Claude Code