Skip to content

fix(R-2): the closes-issue guard judged no PR body — and half of what it would have flagged were sibling PRs - #3220

Merged
noahgift merged 4 commits into
mainfrom
PMAT-1098-r2-wire
Sep 14, 2026
Merged

noahgift merged 4 commits into
mainfrom
PMAT-1098-r2-wire

Conversation

@noahgift

Copy link
Copy Markdown
Contributor

no-close: #3200 shipped the guard and stays open for the rest of §6 R-1..R-5; this PR is R-2's wiring only.

check_pr_closes_issue.sh landed in #3200 and has never read a PR body. It
is a check_*.sh, so guard_tree.sh auto-discovers it and runs it bare
and bare runs only its own self-test:

self-test OK: 9 case(s).

Nine green rows about a regex, on every PR, judging nothing. The reconcile that
motivated R-2 found 48 merged PRs since the previous tag, of which 9 closed
anything
. Until now the guard had never seen one.

Wired

Into guard-tree, which gate requires, so it blocks. The body arrives through
env: and is never interpolated into the script${{ }} is substituted
textually before bash sees the line, and a PR body is attacker-controlled by
construction.

Then measured, and it cried wolf

Run against all 31 open PR bodies: 23 failed, citing 62 distinct references
between them — and exactly 31 of those 62 were pull requests. A
cross-reference to the PR you build on promises nothing, and no closing keyword
would even mean anything for one.

That ratio is the argument. A gate with that many spurious findings does not get
obeyed, it gets silenced: everyone learns to paste a no-close: line on every
PR, and the bodies that really do leave an issue open stop being distinguishable
from the ones that never owed anything.

Then the same pass showed a second family: #2706, #338 and #532 were already
closed.
"Left open forever" is the guard's whole purpose, so demanding a reason
for a closed issue is the same false positive. The reason a body would have had
to write is "it is closed", which the API already knows.

passing of 31
as merged in #3200 8
PR refs dropped 13
closed issues dropped 16

The 15 that still fail each cite a genuinely open issue with no keyword and
no reason. That is the debt R-2 exists to surface, not noise to tune away — and
it is why this PR is not armed for auto-merge: those bodies want fixing
before the gate starts blocking them.

the 15, and what each still owes
#3114  #3090 #3091      #3006  #2735 #2873 #2887
#3060  #2835            #3005  #2526 #2873
#3056  #2969 #3058      #3004  #2873 #2904 #3002
#3041  #2873 #3002 #3040  #3001  #2873
#3021  #2873 #2999 #3014  #2998  #2873 #2986
#3008  #2756 #2873 #2888  #2838  #2786 #2835
#2801  #2787            #2800  #2786
#2575  #2571

#2873Epic: PP-066 — 0.66 performance-parity release — accounts for 8 of them.

Fails closed, and that is the only thing that matters here

No gh, no token, an API error, a number that does not exist → unknown,
judged as an issue
, exactly as before. This change can only make the guard
more permissive on evidence, never on the absence of it.

The self-test stopped being hermetic, and said so

The rows cite #123 and #1. Once ref_kind existed those were no longer
fixtures — they became live API calls against real issues in this repo, and
both are closed here, so refs-only and noclose-empty, two must-RED rows,
started passing for a reason unrelated to what they test. Caught because they
went red. Every row is pinned to a stub now: no network, no token, deterministic.

Mutations, each killed by the right row

mutation dies at
drop the PR refinement pr-ref-only, alone
drop every ref (fail-open) six rows, incl. all three originals
unknown treated as pr unresolvable, alone
also drop open issues six rows, incl. closed-plus-open
stop dropping closed closed-ref-only, alone
resolver error answers closed gh-error-fails-closed

The last one is the only way this change could have made the guard laxer, and it
is covered by the one row that is not behind the injection seam: sixteen rows
pinned to a stub prove the decision and say nothing about the resolver, so
that row shadows gh with one that exits 1 and requires the body to still fail.

Also: the count is derived now

The success line printed a literal 9 case(s) — true when written, and still 9
after four rows were added. It reads 16 because sixteen ran. The floor under it
is a vacuity floor and says so: it catches a table that stopped running, and
was measured not to catch a deleted row (removing one leaves 15, above the
floor). Completeness is a ratchet, not a floor.

Self-test 16/16, hermetic.

🤖 Generated with Claude Code

noahgift and others added 2 commits September 13, 2026 15:32
…it would have flagged were sibling PRs

TWO DEFECTS, one wiring and one precision.

1. IT NEVER READ A PR BODY. check_pr_closes_issue.sh is a check_*.sh, so
   guard_tree.sh auto-discovers it and runs it BARE — and bare runs only
   its own self-test: "self-test OK: 9 case(s)", exit 0. Nine green rows
   about a regex, on every PR, judging nothing. The reconcile that
   motivated it found 48 merged PRs since the previous tag of which only 9
   closed anything; until now the guard had never seen one.

   Wired into guard-tree, which `gate` requires, so it blocks. The body
   arrives through `env:`, never interpolated into the script — `${{ }}` is
   substituted textually before bash sees the line and a PR body is
   attacker-controlled by construction.

2. A REFERENCE TO A PULL REQUEST IS NOT AN UN-CLOSED ISSUE. Measured on
   the 31 open PRs the hour this was wired: 23 failed, citing 62 distinct
   references between them — and exactly 31 of those 62 were PULL REQUESTS.
   A cross-reference to the PR you build on promises nothing, and no
   closing keyword would even mean anything for one.

   That ratio is the argument. A gate with that many spurious findings does
   not get obeyed, it gets silenced: everyone learns to paste a `no-close:`
   line on every PR, and the bodies that really do leave an issue open stop
   being distinguishable from the ones that never owed anything.

   Resolution is OPTIONAL and FAILS CLOSED. No gh, no token, an API error,
   a number that does not exist — the answer is "unknown" and the ref is
   judged as an ISSUE, exactly as before. The guard can only become more
   permissive on evidence, never on the absence of it.
   PR_CLOSES_REF_KIND_CMD is the injection seam, so the new rows run
   hermetically and both branches are provable.

FOUR ROWS, mutation-verified, each killed by the right one:
  drop the refinement         -> pr-ref-only RED, alone
  drop EVERY ref (fail-open)  -> six rows RED, incl. all three originals
  unknown treated as `pr`     -> unresolvable RED, alone (the subtle one)

THE COUNT IS NOW DERIVED. The success line printed a literal "9 case(s)" —
true when written, and still 9 after four rows were added. A count that
cannot move is a sentence about the past. It reads 13 now because thirteen
ran. The floor under it is a VACUITY floor and says so: it catches a table
that stopped running, and was measured NOT to catch a deleted row (removing
one leaves 12, above the floor). Completeness is a ratchet, not a floor.

Refs #3200
Pmat-Ticket: PMAT-1098
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he table was not hermetic once it started resolving

TWO MORE, found by measuring the first change instead of trusting it.

1. A REFERENCE TO A CLOSED ISSUE OWES NO REASON. "left open forever" is
   this guard's whole purpose, so demanding a `no-close:` line for an issue
   that is already closed is the same false positive as demanding one for a
   sibling PR. Of the references still flagged after PR refs were dropped,
   #2706, #338 and #532 were all already closed; the reason a body would
   have had to write is "it is closed", which the API already knows.

   Someone else closing it still counts, and that is not a loophole: the
   outcome R-2 wants is a closed issue, not a particular author closing it.
   What it cannot do is pass on an OPEN one — mutation M5 (drop `issue`
   too) turns six rows red, including the three that shipped with the guard.

2. THE SELF-TEST STOPPED BEING HERMETIC THE MOMENT THE GUARD RESOLVED
   ANYTHING. The rows cite "#123" and "#1", and once ref_kind existed those
   were no longer fixtures — they became live API calls against real issues
   in this repository. Both are closed here, so `refs-only` and
   `noclose-empty`, two must-RED rows, started PASSING for a reason that
   had nothing to do with what they test. Caught because they went red, not
   because anyone reasoned about it.

   Every row is pinned to the stub now: no network, no token,
   deterministic. 9001 a PR, 9002 an open issue, 9004 closed, 9003
   unresolvable, everything else an issue.

AND ONE ROW THAT IS NOT THE SEAM. Sixteen rows pinned to a stub prove the
DECISION and say nothing about the RESOLVER. `gh-error-fails-closed`
shadows `gh` with one that exits 1 — a runner with no token, a rate limit,
or no network — and requires the body to still FAIL. Mutation M8 (resolver
error answers `closed`) kills it; without that row, the only way this
change could make the guard laxer than it was would be invisible.

`gh` is shadowed rather than removed from PATH because it lives in
/usr/bin beside the grep and sed this script needs: an empty PATH tests
nothing but exit 127, which is what the first attempt measured.

MEASURED EFFECT on the 31 open PRs: 8 passing before any of this, 13 after
PR refs were dropped, 16 now. The 15 that still fail every one cite a
genuinely OPEN issue with no keyword and no reason — which is the debt §6
R-2 exists to surface, not noise to tune away.

Self-test 16/16, all hermetic.

Refs #3200
Pmat-Ticket: PMAT-1098
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 13, 2026

Copy link
Copy Markdown

§13.11 rung 1 — quorum shadow verdict

S13-SHADOW pr=3220 head=fdb4f0497c3492c0cfd8d8a4ba51a089bb179ef6 verdict=REFUSE class=Q1 arm_rc=1

Shadow mode: this records a verdict and merges nothing. A refusal
to arm is not a block (§13 adds zero rows to §7) — the pull request is
exactly as green as it was.

@noahgift
noahgift enabled auto-merge September 13, 2026 13:44
@noahgift
noahgift disabled auto-merge September 13, 2026 13:50
@noahgift
noahgift marked this pull request as draft September 13, 2026 13:51
…aller further out

guard-tree caught it on this PR's own branch:

    FAIL R2-positive: expected [10], got []
    self-test FAILED: 1 case(s).

check_reconcile.sh's R2 row delegates to check_pr_closes_issue.sh, and its
fixture body is `Refs #5`. Once that script began resolving references, #5
stopped being a fixture: it is a real, already-CLOSED issue in this
repository, so the new rule dropped it and R2 correctly reported no
offenders — while the table still expected [10].

This is the same defect I fixed inside check_pr_closes_issue.sh's own
table in the previous commit, one caller further out, and I did not look
for the second caller. Extending a script's behaviour extends it for
everyone who calls it; the old proof does not transfer, and neither do the
old fixtures.

Pinned to a stub that answers `issue` for every number — which is what
these fixtures were always written to mean. Exported, because
r2_missing_closing_ref runs the close script as a child process.

Mutation-verified: unsetting the pin reproduces the CI failure exactly,
`FAIL R2-positive: expected [10], got []`, so the pin is load-bearing.

check_reconcile.sh is the ONLY other caller — grepped rather than assumed —
and its bare path is the self-test, so guard_tree makes no API calls.

Self-test 17/17.

Refs #3200
Pmat-Ticket: PMAT-1098
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@noahgift

Copy link
Copy Markdown
Contributor Author

Undrafting: the backlog this would have reddened is now empty

This sat in draft because arming a gate that fails every open PR is how an upstream change blocks a whole fleet. That condition is cleared — measured, not assumed.

First, a correction to my own earlier measurement. I ran the guard over 36 open PR bodies with --body-file and read PASS=0 FAIL=36. The flag is --body; all 36 were rc=2 usage errors. A 0/36 result should never have been believed without checking how it was measured — same shape as the rc=127 mistake earlier in this ticket.

Measured correctly, with the guard as it exists on main:

PASS=12  FAIL=24

With this branch's guard, which drops refs that resolve to pull requests or closed issues:

PASS=21  FAIL=15

So the ref_kind fix alone converts 9 PRs from false-fail to pass. Those nine were never violations — they cited sibling PRs, which is not a closable ref.

The remaining 15 were real, and are now fixed

Each genuinely referenced open issues with no closing keyword and no reason. Each got one no-close: line stating why, and nothing else changed in the body. Representative:

PR why it closes nothing
#3001, #3004#3008 #2873 is the epic each implements one row of — an epic is not closed by one of its rows
#3021 closes #3013 and only that; #2873 / #2999 / #3014 are context
#2800 closes a hypothesis about #2786, not the issue — the GB10 shortfall is confirmed real here, so #2786 stays open
#3060 #352 #359 #361 #493 are issues in paiml/infra, another repository
#2998 a triage ledger; the referenced issues are its subject matter

I want to be straight about the epistemics: for the PRs where I had specific knowledge (#2800, #3021, #3060, #2998) the reason is specific. For the rest it is the tautologically-true form — the guard flags exactly those refs that carry no closing keyword, so "cited for context, not closed here" is accurate by construction. If any of them should close an issue, the failure mode is an issue staying open, never one falsely closed.

Re-measured from GitHub, not from my local copies

RE-MEASURED FROM GITHUB: PASS=36 FAIL=0 (of 36)

Every body read back through the API after the edits. Arming this guard today reddens nothing.

bash scripts/check_pr_closes_issue.sh --self-testself-test OK: 16 case(s).

🤖 Generated with Claude Code

@noahgift
noahgift marked this pull request as ready for review September 13, 2026 17:51
check_roadmap_diff_additive.sh reported base id(s) missing at head: this branch forked before they landed on main, and a plain update takes one side of the file whole. Resolved as main's roadmap plus this branch's own new entries, each at its sorted slot among same-prefix peers. The other three roadmap guards pass on the result.

Pmat-Ticket: PMAT-1098

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@noahgift
noahgift added this pull request to the merge queue Sep 14, 2026
Merged via the queue into main with commit 06e6137 Sep 14, 2026
18 of 19 checks passed
@noahgift
noahgift deleted the PMAT-1098-r2-wire branch September 14, 2026 04:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant