feat(pm): derive a PR's file list three-dot in check-governed-merges — --pr and --branch - #17504
Merged
Merged
Conversation
…(--pr / --branch) `--test` takes a caller-derived path list and nothing said how to derive it. The two obvious derivations disagree on any branch behind `main`: two-dot adds main's own newer commits, so the governance predicate can answer GOVERNED for paths a PR never touched. Adds `--pr <n>` (the API's changed-files list, three-dot by construction, paginated and proven against the PR's own count, renames contributing BOTH paths) and `--branch <ref>` (merge-base three-dot, offline, `--no-renames` so a rename out of a governed path still reads as a hit). Each prints the derivation it used. Both refuse rather than fall back to two-dot when the derivation cannot be made. `--test`'s stdout is unchanged; the three-dot note goes to stderr so the enqueue hook's JSON reading is untouched. Claude-Session: https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK Co-authored-by: Claude <noreply@anthropic.com>
…eproduction A new self-test battery (43 cases): the Link page walk, a rename reaching the predicate as both of its paths, a walk the PR's own count contradicts, the channel chosen once and never spliced mid-walk, every --branch leg on an injected git, and the card's own reproduction run end to end on a real repo — a branch behind a main that has since touched a governed path answers GOVERNED two-dot and NOT governed three-dot, and the verdict is byte-identical through --branch and through --test on the same list. Claude-Session: https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK Co-authored-by: Claude <noreply@anthropic.com>
os-litant
marked this pull request as ready for review
September 10, 2026 18:45
os-litant
enabled auto-merge
September 10, 2026 18:46
This was referenced Sep 10, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #17003
scripts/pm/check-governed-merges.mjs --testis the governance authority every seat runs before flipping ready (#9550), and it takes a path list the CALLER derived. Nothing said how to derive it, and the two obvious derivations disagree on every branch that is behindmain:git diff --name-only origin/main HEADmain's OWN newer commits, rendered as reversions because the branch has not taken themgit diff --name-only origin/main...HEADThe card's measurement on PR #16997: 7 paths two-dot, 2 three-dot, five of them
main's own. The superset fails safe in one direction and unsafe in the other — it can never HIDE a governed hit, but it can manufacture one, and a manufactured hit parks a correct ordinary PR under a rule whose reflex is to escalate rather than to re-derive the input.skills/**is regenerated bygen:skill-refsand moves on ordinary landings, so a branch a few hours old carrying that path in its two-dot diff is not a contrived case.Nothing in the predicate can detect this: a path list is a path list. So the derivation moves INTO the tool.
The two derivations
--pr N(also--pr owner/repo#n, because the register is repo-agnostic and the PM audits five repos) readsGET /repos/OWNER/REPO/pulls/N/files, which is three-dot by construction. It pages at 100 followingLink rel=next, then proves the walk against the PR's ownchanged_filescount.--branch REFcomputesgit merge-base origin/main REFand diffs from it, offline. No network, no token.Both PRINT the derivation they used, above a verdict that is byte-identical to
--test's on the same list.--testkeeps working unchanged, and gains one note — on stderr, so its stdout stays byte-for-byte what it was for the consumers that parse it (.claude/hooks/guard-governed-enqueue.shreads the--jsonform).Three judgement calls, on the four axes
1. An uncomputable merge base is a REFUSAL, never a fallback
git merge-base origin/main REFexits 1 with empty output on a shallow clone. Measured in this container: it cannot compute one for a fetched PR head. The recipe everyone writes —git diff --name-only $(git merge-base origin/main REF) REF— then degrades in the worst possible way: the command substitution collapses to nothing, git reads the remaining single argument as a diff against the WORKING TREE, and the run exits 0 with a superset. Measured on this branch: 650 paths where the branch had none.$?reading and noif cmd; thencan turn it into "not governed".2. A rename contributes BOTH paths, and the local diff is taken
--no-renamesA rename OUT of a governed path is still a change to that path — moving
AGENTS.mdtodocs/AGENTS.mdedits the governed surface. Measured:status: renamedcarriesprevious_filename(measured on PR feat(spec)!: drop the@objectstack/spec/cloudsubpath — control-plane contracts leave, the package format moves to@objectstack/spec/marketplace#17372, 12 renamed entries), so both are read;git diff --name-onlyprints only the NEW path, so the same rename reads as ungoverned. Measured on776d64cd: default prints one path,--no-renamesprints two.--branchtherefore diffs with--no-renames, which spells a rename as a delete plus an add — the same two paths the API reports.AGENTS.mdandCLAUDE.md; a rename is the one edit that moves a path off the register entirely, so this is the case where under-reading is a governance hole rather than a nuisance.3. A short page walk is a refusal too
The changed-files endpoint pages at 100 and truncates at the API's own file ceiling. Measured on PR #17076 (260 changed files): 100 + 100 + 60,
Link rel=nextpresent on the first two responses and absent on the third. Counting a short page as the end is a heuristic that is wrong on any exact multiple of 100, so the walk followsLinkand then proves itself against the PR's ownchanged_filescount. A walk that cannot prove it collected the whole list refuses instead of answering on part of it.--prreuses the sweep's own channel chain and its proxy re-arm, and opens no second HTTP client and wants no second token.A fourth, smaller call: the channel is chosen ONCE, on the
GET /pulls/Nthat reads the count, and every page is then read over that same channel. A walk that fell through to the next channel mid-list would splice two readings, taken at two instants and possibly at two permissions, into one list and call it the PR's.Live readings
--pr 16997— the card's own measured case (merged; the API still serves its files). The card's two-dot reading of this PR was 7 paths, five of themmain's:--pr 17504— this PR's own file list, which is this card's own acceptance criterion (run against the head this body describes):The refusal, on this container's own shallow clone:
Verification
Gate families derived from the FINAL diff with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths passed; the script took the change set from the merge base itself). Every exit code captured BEFORE any pipe.34 derived, 34 run, 34 exit 0. Reconciled:
Verdict lines from the families that read this file:
pnpm check:pm-governed-mergescheck-governed-merges --self-test: 317 assertions(274 before this PR; +43, one new battery)pnpm check:pm-dispatch-gatesdispatch-gates self-test: 1678 cases pass.pnpm check:nul-bytescheck-nul-bytes: OK (scanned 8294 text file(s) ... no raw ASCII control bytes).pnpm check:entry-guardcheck:entry-guard: 246 scripts/ file(s) — every entry guard goes through invoked-as.mjs; 188 export bindings, 188 of them inert on importnode scripts/check-scripts-symbol-anchors.mjs --self-testcheck-scripts-symbol-anchors --self-test: ... population live (3156 live anchors)pnpm check:ratchet-remedy-authoritycheck-ratchet-remedy-authority: 239 scripts swept ...Two roster-silent families the derivation flagged as adjacent, run anyway:
|
pnpm check:pm-governed-prose| 0 |check-governed-prose: 2 instruction surface(s) name all 5 registered governed surfaces (docs/adr/** · .claude/** · skills/** · AGENTS.md · CLAUDE.md) and claim no others.||
node scripts/check-published-list-mirrors.mjs| 0 | (clean) |npx eslint --no-inline-config scripts/pm/check-governed-merges.mjs— exit 0, no output.Path face:
node scripts/pm/check-governed-merges.mjs --test scripts/pm/check-governed-merges.mjs— exit 0, NOT governed.scripts/pm/**is not a governed surface; this PR lands through the queue after review.Ablation: the merge-base refusal, both legs
Mutated on disk from the committed tree, with a
trap ... EXIT INT TERMrestore against an absolute path, and observed rather than assumed:The mutation replaces the refusal with the fallback the card warns about — when
git merge-baseanswers nothing, anchor the diff atorigin/maininstead:Ablated leg — self-test exit 1, 2 failures, and the second is the whole point:
The merge base equals
origin/main— the two-dot anchor — and a fixture branch touching one unrelated file answers GOVERNED on 4 paths, 2 of them on the register. That is the card's defect, produced on demand.Restored leg — byte-identical and green:
Predicted direction was "turns red", and that is what was observed. The ablation left no permanent test file; both legs are quoted here and nowhere in the tree.
What the new battery pins (43 cases)
Pure: the
Linkwalk that ends onrel=nextrather than on a short page; a rename reaching the predicate as BOTH of its paths (andAGENTS.mdtodocs/AGENTS.mdanswering GOVERNED only because of it); a walk the PR's own count contradicts refusing rather than answering on a subset; the channel chosen once and never spliced mid-walk; every--branchleg on an injected git.End to end, on a real temp repo built with real
git— because every pure case above stays green ifmain()simply stops CONSULTING these derivations, the precedent this file already sets for its #13307 wiring: a branch behind amainthat has since touched.claude/hooks/guard.shanswers GOVERNED two-dot and NOT governed three-dot; a branch that renames the root instruction file answers GOVERNED only because the diff is taken--no-renames; the merge-base refusal exits 1 and prints no verdict at all, while the collapsed shell recipe it refuses to imitate really is a superset; a branch that changes nothing is a failure, never a "not governed" answer; two mode flags at once are refused; and the verdict is byte-identical through--branchand through--teston the same list, which is the structural invariant this card asked for.Deviations from the suggested route
--testnote is a five-line block, not literally one line, and it is on stderr rather than stdout. Both for one reason:--test's stdout is a consumed contract (--jsonis parsed by the enqueue hook), and a predicate that changes what it hands its callers in order to warn its humans has broken something in order to say something. The operator still reads the note; the parser still reads the same bytes it always did.--pralso acceptsowner/repo#n, because the register is repo-agnostic across the five governed repos and the sweep already audits all five from one checkout. No new flag was added for it.--prcannot be driven end to end offline, so its page walk, rename reading, truncation refusal and channel stickiness are pinned in-process against an injectedfetch, and the shared verdict emitter is pinned end to end through--branch. The two live--prruns are quoted above.scripts/pm/**is not in any package'sfiles[]; theskip-changesetlabel is applied.Acceptance notes
.claude/hooks/guard-governed-enqueue.shcollects onlyf.filenamefrom the changed-files API and neverf.previous_filename, so a PR that renames a governed file OFF the governed surface reads as NOT governed and the guard permits queueing it. Same defect class as this card, on a file this card's declared surface excludes (.claude/**is governed, and PR feat(pm): refuse a merge group whose queued PR still carriesneeds:contract-review#17484 is in flight nearby). Dedup: the opendomain:skillslane read whole over REST (49 cards) with a positive control — zero hits for rename /previous_filename/ the hook's name.get_files(SKILL.md :605-606). Whether that wrapper surfacesprevious_filenamewas NOT measured here. Successor: whoever acts on the filed card above, since the two share one question.truncated=1thenexit 0). That is a deliberate advisory-guard hedge stated in its own comment, not a defect. Successor: none.Clause-②: no — internal PM tooling; no accept set, no published surface and no authorable metadata moves.
Generated by Claude Code