Skip to content

feat(pm): derive a PR's file list three-dot in check-governed-merges — --pr and --branch - #17504

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-17003-governed-merges-pr-flag
Sep 10, 2026
Merged

feat(pm): derive a PR's file list three-dot in check-governed-merges — --pr and --branch#17504
os-litant merged 2 commits into
mainfrom
claude/issue-17003-governed-merges-pr-flag

Conversation

@os-litant

@os-litant os-litant commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Fixes #17003

scripts/pm/check-governed-merges.mjs --test is 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 behind main:

derivation command what it is
two-dot git diff --name-only origin/main HEAD a SUPERSET — it also carries main's OWN newer commits, rendered as reversions because the branch has not taken them
three-dot git diff --name-only origin/main...HEAD what the PR changes

The 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 by gen:skill-refs and 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) reads GET /repos/OWNER/REPO/pulls/N/files, which is three-dot by construction. It pages at 100 following Link rel=next, then proves the walk against the PR's own changed_files count.
  • --branch REF computes git merge-base origin/main REF and 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. --test keeps 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.sh reads the --json form).

Three judgement calls, on the four axes

1. An uncomputable merge base is a REFUSAL, never a fallback

git merge-base origin/main REF exits 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.

  • Real business need — measured, not speculative: the collapse reproduces in this very container, and the ablation below shows it producing a GOVERNED verdict for a branch that touches one unrelated file.
  • Long-term soundness — no workaround: a derivation that cannot be made is stated as such on exit 1, which is already this file's code for "could not answer". A quieter fallback is precisely the defect the card is about.
  • Structurally hard for an AI to get wrong — this is contract tightening at the producer, not tolerance at the consumer: the refusal is loud, names the reason and the remedy, and prints no verdict at all, so no $? reading and no if cmd; then can turn it into "not governed".
  • Startup-stage focus — no new surface: one refusal path inside a mode that had to exist anyway.

2. A rename contributes BOTH paths, and the local diff is taken --no-renames

A rename OUT of a governed path is still a change to that path — moving AGENTS.md to docs/AGENTS.md edits the governed surface. Measured:

--branch therefore diffs with --no-renames, which spells a rename as a delete plus an add — the same two paths the API reports.

  • Real business need — the governed surfaces include two exact repo-root files, AGENTS.md and CLAUDE.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.
  • Long-term soundness — one reading of "changed paths" for both derivations, so the two modes cannot disagree.
  • Structurally hard to get wrong — the strict answer is the default and there is no lenient spelling to reach for; a seat cannot opt into the under-reading without editing the script.
  • Startup-stage focus — no option, no flag, no staged transition: 「项目在创业阶段,用户也很少,短期不考虑渐进。」 The old path is read, full stop.

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=next present 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 follows Link and then proves itself against the PR's own changed_files count. A walk that cannot prove it collected the whole list refuses instead of answering on part of it.

  • Real business need — 260 files is a live PR in this repo today (the standing Version Packages PR), not a hypothetical.
  • Long-term soundness — the proof is the API's own count, not a guess about page shapes.
  • Structurally hard to get wrong — a truncated list is a SUBSET, the one direction that can hide a governed hit outright, so it is the direction that must fail closed.
  • Startup-stage focus — a 40-page cap and one extra GET; no caching layer, no retry policy, no new client. --pr reuses 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/N that 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 them main's:

$ node scripts/pm/check-governed-merges.mjs --pr 16997
derived from GET /repos/objectstack-ai/objectstack/pulls/16997/files (three-dot by construction): 2 path(s) from 2 changed file(s), over 1 page(s).
governed-surface predicate: 0 of 2 path(s) hit the register (5 surfaces, repo-agnostic).
  NOT governed — ordinary queue landing applies to a PR with exactly this file list.
      Derived from GOVERNED_SURFACES, not recalled. Re-run on the FINAL file list: the register
      has grown several times in two days, and a reading taken earlier in the session is recall.
exit 0

--pr 17504 — this PR's own file list, which is this card's own acceptance criterion (run against the head this body describes):

$ node scripts/pm/check-governed-merges.mjs --pr 17504
derived from GET /repos/objectstack-ai/objectstack/pulls/17504/files (three-dot by construction): 1 path(s) from 1 changed file(s), over 1 page(s).
governed-surface predicate: 0 of 1 path(s) hit the register (5 surfaces, repo-agnostic).
  NOT governed — ordinary queue landing applies to a PR with exactly this file list.
      Derived from GOVERNED_SURFACES, not recalled. Re-run on the FINAL file list: the register
      has grown several times in two days, and a reading taken earlier in the session is recall.
exit 0

The refusal, on this container's own shallow clone:

$ node scripts/pm/check-governed-merges.mjs --branch refs/tmp/pr17298
cannot derive --branch refs/tmp/pr17298's file list — `git merge-base origin/main refs/tmp/pr17298` computed nothing (it exited non-zero with empty output) — a shallow clone or unrelated histories. There is no three-dot list to take.
    Refusing rather than falling back to a two-dot diff (`origin/main HEAD`): on a branch
       behind main that list carries main's own newer files, so this predicate would answer
       GOVERNED for paths the PR never touched (#17003). A wrong answer is worse than none.
    Remedy: `git fetch --deepen 200 origin main` (or `--unshallow`) until the two histories meet, then re-run.
exit 1

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:

$ node scripts/pm/dispatch-gates.mjs --ran <recorded list> --repo objectstack-ai/objectstack
Run reconciliation — 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN.
  EXIT CODES — all 34 accounted famil(ies) carry one, so the NOT-MEASURED count above is DERIVED from them.
dispatch-gates --ran: 34 derived famil(ies) accounted for — 34 run, 0 NOT-MEASURED (a DERIVED zero — all 34 recorded an exit code and none of them is 3).
exit 0

Verdict lines from the families that read this file:

family exit its own verdict line
pnpm check:pm-governed-merges 0 check-governed-merges --self-test: 317 assertions (274 before this PR; +43, one new battery)
pnpm check:pm-dispatch-gates 0 dispatch-gates self-test: 1678 cases pass.
pnpm check:nul-bytes 0 check-nul-bytes: OK (scanned 8294 text file(s) ... no raw ASCII control bytes).
pnpm check:entry-guard 0 check:entry-guard: 246 scripts/ file(s) — every entry guard goes through invoked-as.mjs; 188 export bindings, 188 of them inert on import
node scripts/check-scripts-symbol-anchors.mjs --self-test 0 check-scripts-symbol-anchors --self-test: ... population live (3156 live anchors)
pnpm check:ratchet-remedy-authority 0 check-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.mjsexit 0, no output.

Path face: node scripts/pm/check-governed-merges.mjs --test scripts/pm/check-governed-merges.mjsexit 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 TERM restore against an absolute path, and observed rather than assumed:

HEAD blob      = c1e519ffbe21d19d896faec1d77aba5eb243ca1c
worktree blob  = c1e519ffbe21d19d896faec1d77aba5eb243ca1c
--- BEFORE mutation ---   original line count : 1   marker count : 0
--- AFTER mutation  ---   original line count : 0   marker count : 1
  mutated blob        = 9b269509c33071b75a23cd2cb65b996cd8975f20

The mutation replaces the refusal with the fallback the card warns about — when git merge-base answers nothing, anchor the diff at origin/main instead:

const mergeBase = mergeBase0.ok && isObjectId(mergeBase0.out) ? mergeBase0 : baseRead; // ABLATION

Ablated leg — self-test exit 1, 2 failures, and the second is the whole point:

  an-uncomputable-merge-base-REFUSES-it-never-falls-back-to-two-dot: {"ok":false,"reason":"`git diff` failed ..."}
  an-uncomputable-merge-base-exits-1-with-a-refusal-and-NO-verdict-at-all: status=3 out=derived from `git diff --name-only --no-renames 21d8896627 7185ac7a6d` (three-dot): 4 path(s).
    origin/main = 21d8896627, lonely = 7185ac7a6d, merge-base = 21d8896627.
governed-surface predicate: 2 of 4 path(s) hit the register (5 surfaces, repo-agnostic).
   GOVERNED — a human merge is the rev...

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:

HEAD blob     = c1e519ffbe21d19d896faec1d77aba5eb243ca1c
worktree blob = c1e519ffbe21d19d896faec1d77aba5eb243ca1c
git diff HEAD --quiet exit=0
restored self-test exit=0 — 317 assertions

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 Link walk that ends on rel=next rather than on a short page; a rename reaching the predicate as BOTH of its paths (and AGENTS.md to docs/AGENTS.md answering 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 --branch leg on an injected git.

End to end, on a real temp repo built with real git — because every pure case above stays green if main() simply stops CONSULTING these derivations, the precedent this file already sets for its #13307 wiring: a branch behind a main that has since touched .claude/hooks/guard.sh answers 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 --branch and through --test on the same list, which is the structural invariant this card asked for.

Deviations from the suggested route

  • The --test note 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 (--json is 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.
  • --pr also accepts owner/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.
  • --pr cannot be driven end to end offline, so its page walk, rename reading, truncation refusal and channel stickiness are pinned in-process against an injected fetch, and the shared verdict emitter is pinned end to end through --branch. The two live --pr runs are quoted above.
  • No changeset: nothing published moves. scripts/pm/** is not in any package's files[]; the skip-changeset label is applied.

Acceptance notes

Clause-②: no — internal PM tooling; no accept set, no published surface and no authorable metadata moves.


Generated by Claude Code

…(--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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants