Skip to content

fix(governance): the register's only usable write path was unguarded - #2879

Merged
POWERFULMOVES merged 11 commits into
mainfrom
fix/register-write-path-failclosed
Sep 2, 2026
Merged

POWERFULMOVES merged 11 commits into
mainfrom
fix/register-write-path-failclosed

Conversation

@POWERFULMOVES

Copy link
Copy Markdown
Owner

The defect

claim-collision-pre.py is wired to PreToolUse on Write, Edit and Bash. The paths were not equivalent. Measured on main @ 94224d955, identical content, against feat/hirag-mcp-bridge — a lane COWORK-CLAUDE genuinely holds at L1086:

path exit verdict
Write 2 blocked, named the lane and the holder
Bash 0 permissionDecision: "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 <<EOF looks entirely benign to one of those.

This was load-bearing, not academic. Four delivery agents in this session had no Write or Edit tool — 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 own 2026-09-01T19:08:57Z row 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.

  • Statically recoverable (heredoc body, single-quoted echo/printf literal) → the same evaluate_claims() the Write path runs → exit 2 with a byte-identical message.
  • Not recoverable (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 Write tool 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:

make -C pmoves register-claim   OWNER='...' BRANCH=fix/x TTL=72h SCOPE='...'
make -C pmoves register-release OWNER='...' BRANCH=fix/x SCOPE='...'
make -C pmoves register-docs    ANCHOR='...' TEXT_FILE=section.md

pmoves/tools/register_append.py reads 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 with O_APPEND.

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 (50 in at least one), worst +5h05m. 368 of 412 rows (89.3%) carry :00 seconds — hand-rounded, not clock-read. (The lane brief said 41/404 and 89.4% via git blame; my count is per-commit and includes REVIEW/UPDATE/HANDOFF kinds and rebase re-adds. The :00 figure corroborates independently to within 0.1pp.)

Existing rows are reported, never rewritten — they are other nodes' provenance. make -C pmoves register-postdate-sweep reports them; the gate judges only rows a PR adds, so the count can go down and cannot go up. Same shape as validate-command-anchors / validate-composes / validate-dockerfile-paths.

Proof every gate says no — 22/22

Every deny observed refusing, with exit codes:

exit=2  WRITE  colliding claim            (unchanged -- not weakened)
exit=0  WRITE  non-colliding claim
exit=2  BASH   heredoc append, colliding  <- the closed hole
exit=0  BASH   heredoc append, non-colliding
exit=2  BASH   tee -a heredoc, colliding
exit=2  BASH   echo literal append, colliding
exit=0  BASH   echo literal append, non-colliding
exit=2  BASH   sed -i / cp / python-computed row / variable / unquoted-heredoc expansion
exit=2  BASH   truncating rewrite (append-only)
exit=0  BASH   grep / read-redirected-elsewhere / python READING / git add / git diff
exit=0  BASH   unrelated file whose content merely mentions the register

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:

  1. Fail-open. echo "$ROW" >> REG exited 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.
  2. False deny. A heredoc writing /tmp/notes.md whose 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.
  3. Deadlock. 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 redirected REGISTER still 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 inside evaluate_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_write was. Make targets are anchored ~30 lines from #2858's insertion point to avoid a Makefile conflict. Hook tests are in the existing pmoves/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-docs mode 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 unchanged on 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/6641 hook (29 pre-existing unchanged, 12 new/retargeted), 8 postdate, 17 sanctioned 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

POWERFULMOVES and others added 2 commits September 2, 2026 07:10
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
@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are limited based on label configuration.

🏷️ Required labels (at least one) (1)
  • coderabbit

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: 441a41e7-da30-44a4-a16b-f91a807a0401

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-02T11:42:58.188281Z 772fdcc PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions github-actions Bot added workflows GitHub Actions workflows docs Documentation governance AGNOTE register / agent definitions / damage-control hooks labels Sep 2, 2026
POWERFULMOVES and others added 3 commits September 2, 2026 07:41
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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread .claude/hooks/governance/claim-collision-pre.py Outdated
Comment thread pmoves/tools/register_append.py Outdated
Comment thread pmoves/tools/register_postdate_check.py
Comment thread pmoves/Makefile Outdated
Comment thread pmoves/Makefile Outdated
POWERFULMOVES and others added 6 commits September 2, 2026 13:57
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
@POWERFULMOVES
POWERFULMOVES merged commit e473e0d into main Sep 2, 2026
32 checks passed
@POWERFULMOVES
POWERFULMOVES deleted the fix/register-write-path-failclosed branch September 2, 2026 19:14
POWERFULMOVES added a commit that referenced this pull request Sep 2, 2026
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
POWERFULMOVES added a commit that referenced this pull request Sep 3, 2026
…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>
POWERFULMOVES added a commit that referenced this pull request Sep 3, 2026
…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>
POWERFULMOVES added a commit that referenced this pull request Sep 3, 2026
…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>
POWERFULMOVES added a commit that referenced this pull request Sep 4, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation governance AGNOTE register / agent definitions / damage-control hooks workflows GitHub Actions workflows

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant