Closing Keywords and Issue State

GitHub's closing-keyword parser reads a keyword and the reference immediately after it. It reads nothing else. Not the negation in front of the keyword, not the possessive behind the number, not the paragraph two lines down that says the issue stays open. A pull-request body is prose written for people, and one substring of it is an instruction to a machine that cannot tell the two apart.

That is a latent trap in every body. On one day it fired three times in this repository.

The three misfires

Each was confirmed against the API: closingIssuesReferences registered a closing link on all three pull requests, and each issue's own timeline shows the merging app closing it within two seconds of the merge.

pull request the text that fired what happened
#5201 **Does not close #5057** #5057 (sev:H) closed at merge + 2 s; reopened by hand 2 min later
#5174 Closes #2299's classification half #2299 closed at merge + 1 s, against the body's own statement that it stays open for the half the change did not fix; reopened 91 min later with the reason written out
#5190 … closed #2299 against its own body's statement … #2299 closed a SECOND time, 57 minutes after that deliberate reopen — by a documentation pull request narrating the first misfire in the past tense

Three different shapes, one mechanism:

🚨 The second and third closes landed on an issue that had been deliberately reopened with the reason written out. Nothing in the mechanism notices that. An issue's history is not an input to the parser, so "somebody already decided this stays open" is worth exactly as much as the sentence around the keyword.

Why a wrong close is worse than it looks

The failure is silent and inverted. The pull request merged, the fix is on main, and the issue reads closed — which is the safe-looking direction, and therefore the one nobody re-checks:

What may close on a merge, and what may not

A merge puts the fix on main. What a severity label gates is whether the defect is gone from the running portal, which a merge cannot establish: the image still has to be built, sealed, rolled, and the behaviour exercised against the running address. So a sev:B / sev:H issue closes on post-roll production verification, never on a merge — policy severity-closes-on-verification. sev:M, sev:L, chore, enhancement and documentation issues carry no such obligation and close on a merge as they always did.

🚨 A duplicate is not an exception, it is a different close. A closing keyword closes an issue as completed. An issue that is a duplicate, superseded or not-planned wants that state reason instead — so it gets Refs #N in the body and a close by hand, with the comment that says where the record moved.

The gate

Closing keywords (no accidental close)scripts/check-closing-keywords.py, run as its own job on every pull_request and carried in the required check's needs:. Three checks, each with its own message and its own remedy:

check fires on remedy the message names
negated keyword a keyword bound to #N with a negation within two words in front of it Refs #N / see #N — and if you did mean to close it, delete the negation
release-blocking close a keyword bound to an issue currently labelled sev:H or sev:B Refs #N now, close it after the roll — or declare the escape below
possessive reference a keyword bound to #N immediately followed by 's Fixes the <half> of #N — the keyword is no longer before the number, so nothing closes and the sentence still reads

🚨 Three checks and not two, because two would have missed one of the three incidents. Measured at the state each pull request had AT ITS MERGE: the negation check catches #5201 alone; the severity check catches #5201 and #5190; #5174 is caught by neither, because #2299 was labelled sev:M when it merged and was relabelled sev:H only after the reopen, 91 minutes later. The possessive check is what catches it, and it is the narrowest of the three.

What the gate mirrors, and what it deliberately does not improve on

The detector reproduces the parser rather than correcting it, because a gate that is cleverer than the thing it guards produces verdicts an author cannot act on:

The escape

A pull request that legitimately closes a sev:H/sev:B — because the verification has already happened — declares it in the body, per issue:

Verified-closing: #5057 — verified on the rolled portal: the release node now names the
attempted id, and the pre-fix wording has not recurred in ten minutes of logs.

The shape is the house's declaration idiom (Pairs-with:, Implementers:, Mirror-sync:) and its spirit is Transitional Allow Entries: it names exactly what it releases and it carries a reason.

No skip-trapdoor

The severity check reads live labels, so it has an external input, and it is wired the way AGENTS.md requires of one:

It needs no secret: GITHUB_TOKEN reads this repository's issues, so the gate also runs on fork pull requests.

Base rate

Measured over the 99 merged pull requests in the two days to 2026-09-22T11:17Z: 16 carry a closing keyword bound to a reference at all, and 7 would be red — the three incidents above, plus four more that each closed a sev:H issue on merge (#4973→#1172, #4984→#4761, #5070→#4722, #5183→#5177). The other nine close sev:M, sev:L, chore, enhancement or unlabelled issues and pass. Zero false positives over those 99 real bodies.

So this gate is not a rarity like Implementers: — it meets roughly one merge in fourteen, and six of those seven are the defect rather than the gate being noisy: six release-blocking issues were taken out of the readiness count by a merge in two days.

The self-test, and its control

check-closing-keywords.py --self-test classifies a body per case and prints the count and the red/green split — that line is in every run of the gate's job, and it is the number to read rather than one copied into this page, which goes stale on the next case anybody adds. What the cases cover: the three real bodies as the API returned them, every negation and possessive shape (including the two-sentences-back and not only does this fix cases that must NOT fire), the four reference spellings GitHub accepts, a keyword in a heading and in a table cell, unfixed/ prefixes as non-keywords, the Closes #A, #B trap, a keyword in a code span / a fence / an HTML comment, Refs #N, a body with no reference at all, and both arms of every escape rule.

🚨 A self-test that passes against a detector which has stopped detecting proves nothing, so neutered-detector controls run afterwards, each disabling exactly one arm — the negation function, the severity label set, the possessive group, the escape's refusals. The assertion is that the case list then goes RED. A neutered arm that still passes every case means the cases do not cover it, and the self-test fails saying so.

The same reasoning covers the gate's cost, which is one API read per distinct local issue: the resolver is memoised per run and the self-test counts what it was asked (one issue named four times under three shapes must be resolved once), and a body naming more distinct issues than the cap is refused up front rather than discovered by the job being killed at its five-minute limit — a killed job has no verdict, which is indistinguishable from the gate not running. Both of those assertions are themselves controlled: removing the memo, or the cap, turns the self-test red.

What this does not establish