Skip to content

fix(closing-ref): re-run when the title changes, and let a child PR be truthful (backend#2556) - #360

Merged
LukasWodka merged 3 commits into
developfrom
fix/2556-closing-ref-gate
Aug 27, 2026
Merged

fix(closing-ref): re-run when the title changes, and let a child PR be truthful (backend#2556)#360
LukasWodka merged 3 commits into
developfrom
fix/2556-closing-ref-gate

Conversation

@LukasWodka

@LukasWodkaLukasWodka commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Closes tracebloc/backend#2556
Closes tracebloc/backend#2616

Two defects in one gate, fixed together because either alone leaves the other's remedy unusable: #2616 widens what satisfies the check, and #2556 is what lets a PR act on that by editing its body.

Verified first, because three tickets in this queue turned out already fixed

Measured on origin/develop before writing anything:

ClaimState on develop
set-pr-status-caller.yml omits editedtruetypes: [opened, reopened, ready_for_review, converted_to_draft]
closing-ref-gate.py reads only closingIssuesReferencestrue — the query has no body field and no other link source
Last change to either file616daa0 (#314, the original gate) / 2017a1c (#349, an unrelated mint scope)

Neither was fixed. a28adf2 (#354, merged today) is the docs workaround for #2616 — it wrote "keep the number out of the subject" into the canon after four PRs in one day (#349, #350, #352, #353) went red for correctly saying Part of. This PR fixes the gate and reverts that advice.

backend#2556 — the gate reads the title and never re-runs when the title changes

closing-ref decides its verdict from the PR title and body; edited is the only event GitHub fires when either changes. So the two inputs the gate reads were the two inputs that could change without re-running it.

That is a bypass, not a gap: open a PR titled chore: tidy up, the gate records NOTHING_NAMED and goes green, then retitle it to fix(1234): … with nothing linked. No event fires, the green stands. It also made remediation unusable in the other direction — 20 sync PRs had to be cleared with 20 manual gh run rerun calls (backend#2555).

Fixed with the same one-word change, and the same reasoning comment, as fr-gate-caller.yml under backend#1945 (fd31ef4 / .github#338).

A consequence that had to be guarded, not discovered later

Every trigger this workflow had was reachable only on an open PR, so set-status writing Status unconditionally was always correct. edited is not: GitHub fires it when a merged PR's title or body is edited, and this is the one board writer in the fleet that does not compare pipeline rank (advance-deploy-env.yml does).

Without a guard, fixing a typo in a shipped PR's description would drag its card from Prod back to Code review. Both jobs are now if: …pull_request.state == 'open', and both conditions are mutation-pinned.

backend#2616 — one satisfying form, and a child PR cannot truthfully use it

closingIssuesReferences is populated only by a closing keyword, so a PR whose title named a ticket had exactly one satisfying form: promise to close it. For this org's most common shape — a parent ticket with several child PRs titled type(NNNN): …, none of which finishes it — the only remedies were a false Closes, or deleting the number from the title, which passes the check by removing the traceability the check exists to enforce.

A declared non-closing body reference now satisfies the title, reported as its own state (MENTIONED) so a green run still says which promise was made:

Title names: #2284
closingIssuesReferences: none
Body references (`Part of`): tracebloc/backend#2284
Every ticket the title names is referenced:
#2284 -- declared body reference (does not close it)

That is .github#356, the PR #2616 was filed from, passing truthfully. WRONG_REPO is unchanged and is decided before any body reference is consulted — a truthful Part of tracebloc/backend#304 must not mask a Closes #304 that closes .github#304 on merge. A title naming a ticket referenced no way at all still fails; that was the real defect and it still fires.

Derived, not restated

The non-closing vocabulary is parsed out of org-standards.md, which already declares the org's partial-work form. Add Refs <owner>/<repo>#N to the canon and the gate accepts it with no code change. The derivation fails closed in both directions — an unreadable canon and a canon declaring no non-closing form are separate cannot-tells (exit 2), never a silent reversion to closing-only, because reverting is the defect itself.

GitHub's closing keywords are the one list here that cannot be derived from anything in this org, so they are named, cited, and used only to subtract. A mutation empties them and the suite reddens.

Docs updated in the same PR

The canon and CLAUDE.md carried two statements this change makes false — that the check fails unless the body carries Closes, and to keep the number out of the subject. Both rewritten; standards-sync.classify() reports IN_SYNC. The 18 other repos' CLAUDE.md copies will drift until standards-sync.yml runs, which is its normal remediation path.

Evidence

closing-ref-gate-selftest 156 assertions, all passed (was 109)
closing-ref-gate-mutations 53 mutations, 0 stale, 0 uncaught (was 36)
make check green
ruff / actionlint / shellcheck / house-rules clean
standards-sync selftest 60 checks, 0 failed

Two of the new mutations were genuinely instructive rather than decorative: the multi-word-keyword mutation was first scored UNCAUGHT because it killed the suite instead of reddening a case (value returns the exception text, and iterating that string raised IndexError). Both follow-up commits exist because the harness refused to count a broken harness as coverage.

The mutation harness now rewrites the two workflow files as well as the checker. edited and the open-state guards are guarantees declared in YAML, and rule 5 does not exempt a guard for living in a different language.

The fleet still needs the one-word caller change — measured, not assumed

set-pr-status.yml is a reusable in repo-inventory.yml, not a copy (only add-to-kanban.yml is byte-compared), and caller-drift.py matches on uses: content. So this PR does not put the fleet into drift, and .github did not need to be sequenced last.caller_inputs models code-quality.yml only — nothing in the org asserts caller trigger types today.

Measured 2026-08-27 across all 20 repos carrying the caller (filename is set-pr-status.yml in 18 of them, set-pr-status-caller.yml here, and release-train's lives on main): 20 of 20 are missing edited. This PR fixes .github's own. The other 19 need the identical one-word change to their own copies before the bypass is closed for them — the same 19-PR shape backend#1945 used.

Also worth naming: callers pin @main, and quality-ref defaults to "main", so the checker change takes effect fleet-wide only after the normal develop → staging → main promotion. Nothing here shortcuts that.

Live proof of backend#2556, run on this PR

edited is on this PR's head branch, and GitHub takes a pull_request workflow definition from the PR's own ref — so the fix was testable before merging. #2556 asks for exactly this test: retitle a PR and require the check to re-run.

Set PR Status runs on this branch, all pull_request, zero pushes after the first:

#time (UTC)what happened
115:15:50opened — the only run the old trigger set would have produced
215:16:14edited (GitHub processing the body's cross-references)
315:16:33edited
415:21:16edited — title changed to (backend#9999999), a ticket this PR links nowhere
515:24:08edited — title restored

Two deliberate title edits, two new runs, no push. Under [opened, reopened, ready_for_review, converted_to_draft], runs 2–5 do not exist — that is the bypass: the verdict from run 1 would have stood over both retitles.

Worth recording, because it pins down what the fix had to be: the checker reads the title live from the API, not from the event payload, so a queued run self-corrects to the current title. The verdict therefore tracks the title by construction, and the only thing ever missing was an event to trigger a run at all. That is precisely what #2556 diagnosed, and the trigger is the whole fix.

Run 4 was still queued on GitHub's backlog when the title was restored, so its red was never observed live. The red path is covered by the selftest and by the mutation that strips edited from the caller.

Deliberately not done

  • No fleet rollout in this PR. 19 caller PRs are their own change; bundling them would make this undiffable and they are independently revertible.
  • set-status / closing-ref was not armed as a required context. It is required nowhere (#2616 measured actionlint, gate, quality / *, selftests on .github/develop), and arming a gate is a separate decision that belongs after the compliance backlog — rule 4, and the reusable's own header says so.
  • No fleet-wide trigger audit added. The new selftest asserts this repo's caller only, and says so in a comment so a green run is not overread. A fleet assertion needs a network audit in caller-drift.py; that is real work, not a line here.
  • Cross-reference graph not read, though #2616 offered it as an option. A bare body mention already creates a cross-reference, so reading it would accept any incidental prose mention — including the loose #N this gate deliberately refuses in a title. The explicit declared keyword is the whole signal.
  • No merge, no force-push, no reviewer requested.

🤖 Generated with Claude Code

LukasWodkaand others added 3 commits August 27, 2026 17:06
…e truthful (backend#2556, backend#2616)
Two defects in one gate, fixed together because either alone leaves the other's
remedy unusable.
BACKEND#2556 -- THE GATE READS THE TITLE AND NEVER RE-RUNS WHEN IT CHANGES.
`closing-ref` decides its verdict from the PR title and body; the callers
triggered on `[opened, reopened, ready_for_review, converted_to_draft]`, and
`edited` is the only event GitHub fires when either field changes. So the two
inputs the gate reads were the two inputs that could change without re-running
it. That is a bypass, not a gap: open a PR titled `chore: tidy up`, the gate
records NOTHING_NAMED and goes green, then retitle it to `fix(1234): ...` with
nothing linked -- no event fires, the green stands. It also made the remediation
unusable in the other direction: 20 sync PRs had to be cleared with 20 manual
`gh run rerun` calls (backend#2555). Same one-word fix, same reason, as
`fr-gate-caller.yml` (backend#1945).
A CONSEQUENCE THAT HAD TO BE GUARDED, not discovered later. Every trigger this
workflow had was reachable only on an OPEN PR, so `set-status` writing Status
unconditionally was always correct. `edited` is not: GitHub fires it when a
MERGED PR's title or body is edited, and this is the one board writer in the
fleet that does not compare pipeline rank. Without the new guard, fixing a typo
in a shipped PR's description would drag its card from `Prod` back to
`Code review`. Both jobs are now `if: ...state == 'open'`.
BACKEND#2616 -- ONE SATISFYING FORM, AND A CHILD PR CANNOT TRUTHFULLY USE IT.
The checker read `closingIssuesReferences` and nothing else, and that field is
populated only by a closing keyword. So a PR whose title named a ticket had
exactly one satisfying form: promise to close it. For this org's most common
shape -- a parent ticket with several child PRs titled `type(NNNN): ...`, none
of which finishes it -- the only remedies were a FALSE `Closes`, or deleting the
number from the title, which passes the check by removing the traceability the
check exists to enforce. Four PRs in one day took the second (.github#349, #350,
#352, #353) and #354 wrote that workaround into the canon.
A declared NON-CLOSING body reference now satisfies the title, reported as its
own state (`MENTIONED`) so a green run still says which promise was made. The
closing link stays the stronger form, and `WRONG_REPO` is decided BEFORE any
body reference is consulted -- a truthful `Part of tracebloc/backend#304` must
not mask a `Closes#304` that closes `.github#304` on merge.
DERIVED, NOT RESTATED (rule 1). The non-closing vocabulary is PARSED out of
`org-standards.md`, which already declares the org's partial-work form. Add
`Refs <owner>/<repo>#N` to the canon and the gate accepts it with no code
change. The derivation fails closed in both directions: an unreadable canon and
a canon declaring no non-closing form are separate cannot-tells (exit 2), never
a silent reversion to closing-only -- reverting is the defect itself. GitHub's
closing keywords are the one list here that cannot be derived from this org, so
they are named, cited, and used only to subtract.
The canon and CLAUDE.md carried two statements this change makes false -- that
the check fails unless the body carries `Closes`, and to keep the number out of
the subject. Both are rewritten; classify() reports IN_SYNC.
EVIDENCE
closing-ref-gate-selftest 156 assertions, all passed (was 109)
closing-ref-gate-mutations 53 mutations, 0 stale, 0 uncaught (was 36)
ruff / actionlint / shellcheck / house-rules clean
standards-sync selftest 60 checks, 0 failed
The mutation harness now rewrites the two WORKFLOW files as well as the checker:
`edited` and the open-state guards are guarantees declared in YAML, and rule 5
does not exempt a guard for living in a different language.
Part of tracebloc/backend#2556
Part of tracebloc/backend#2616
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lling the suite
The multi-word-keyword mutation was scored UNCAUGHT for the right reason: it made
`evaluate` raise, which killed the suite mid-run, and the harness refuses to
count a broken harness as coverage. `evaluate` grew a path that can raise for a
NEW reason (the derivation from org-standards.md coming back empty), so the
positive cases now go through a wrapper, exactly as `value` already does for
every other call.
Part of tracebloc/backend#2616
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ead of crashing
`value` returns the exception TEXT on a refusal, and a string is iterable -- so a
case that walked the derived list iterated characters and raised IndexError on the
space in "RAISED Unreadable: ...". Normalised once. Found by the multi-word-keyword
mutation, which is the job: it was scored UNCAUGHT for breaking the harness rather
than being caught by a case.
Part of tracebloc/backend#2616
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LukasWodkaLukasWodka self-assigned this Aug 27, 2026
@LukasWodkaLukasWodka changed the title fix(closing-ref): re-run when the title changes, and let a child PR be truthful (backend#2556)fix(closing-ref): re-run when the title changes, and let a child PR be truthful (backend#9999999)Aug 27, 2026

@saadqbalsaadqbal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not a verdict yet — five checks still running. One thing you can fix while they do:

the title cites backend#9999999, which doesn't exist (Could not resolve to an issue or pull request with the number of 9999999). The body is correct — Closes tracebloc/backend#2556 and Closes tracebloc/backend#2616 both link properly — so this is title-only and the gate itself will pass, since it reads GitHub's computed closingIssuesReferences and not the subject line. It's just going to read oddly forever on the one PR whose subject is ticket-reference correctness.

On the substance, both findings look right to me and the second one is the sharper:

"The only remedies were a false Closes, or deleting the number from the title, which passes the check by removing the traceability the check exists to enforce." That's the shape worth naming — a gate satisfiable by deleting its own subject. Four PRs in one day going red for correctly saying Part of is the measurement that makes it a defect rather than a preference.

And #2556 being a bypass rather than a gap is the right classification: chore: tidy up records NOTHING_NAMED and goes green, then a retitle to fix(1234): … fires no event and the green stands. Twenty manual gh run rerun calls to clear the sync batch is the same bug pointing the other way.

Worth saying explicitly since I approved it: this supersedes the advice in #354, which I reviewed two days ago. That was the docs workaround written after those four red PRs; fixing the gate is the better answer and reverting the advice with it is correct, not a regression.

I'll pick this up for a verdict once the checks land.

@LukasWodkaLukasWodka changed the title fix(closing-ref): re-run when the title changes, and let a child PR be truthful (backend#9999999)fix(closing-ref): re-run when the title changes, and let a child PR be truthful (backend#2556)Aug 27, 2026
@LukasWodka
LukasWodka requested review from aptracebloc and saadqbal and removed request for saadqbalAugust 27, 2026 15:44

@saadqbalsaadqbal left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Title fixed — (backend#2556) now, and the head is unchanged at bcf026e8, which is a fair demonstration of the bug this PR closes: the correction is invisible in the SHA, so nothing that keys on the head would notice it. Re-reviewing meant re-reading the title, not re-reading a diff.

Verified the substance rather than resting on my earlier comment:

edited is the whole of the #2556 fix and the reasoning is right — it is the only event GitHub fires when a title or body changes, so without it the gate's verdict outlives the thing it judged. Same one-word change as fr-gate-caller.yml under backend#1945, which is the right precedent to reuse rather than invent a mechanism.

The second-order consequence is the part I'd have gone looking for, and you found it first. Adding edited means GitHub also fires on a merged PR's title or body edit, and this job would then write Code review to a card long past it. Before edited, every trigger implied an open PR, so the unconditional write was always correct — the new event breaks an invariant the old ones held implicitly. That is exactly the class of thing that ships.

And guarding on state == 'open' rather than action != 'edited' is the better fix for both stated reasons. The event-name guard would have kept the demotion out and disabled the board write for a draft toggled by an edit — and it would encode the accident of which event exposed the bug rather than the rule. "The rule is that this job describes an open PR's review state" is the invariant; guarding on it means the next trigger someone adds is covered without a second thought.

The #2616 half stands as I read it before: closingIssuesReferences being populated only by a closing keyword left a child PR choosing between a false Closes and deleting the number — "which passes the check by removing the traceability the check exists to enforce." Four PRs red in one day for correctly saying Part of is the measurement that makes it a defect.

Green, no threads, 411 lines of selftest and 113 of mutations behind it. 👍

@LukasWodka
LukasWodka merged commit 70e2b2d into developAug 27, 2026
29 checks passed
@LukasWodka
LukasWodka deleted the fix/2556-closing-ref-gate branch August 27, 2026 15:54
Sign up for freeto 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.

2 participants

@LukasWodka@saadqbal