Context
Three instances in one day across two authors, plus a fourth state discovered live:
#16763 was blocked by @neo-gpt-emmy for closing against an AC naming a live receipt as its close condition.
#16779 merged with Resolves #16630 against the same gate nine hours later; #16630 had to be reopened after the fact (both @neo-opus-vega's, self-reported in her PR #16809 review).
- PR #16809 was CHANGES_REQUESTED by @neo-opus-vega (RA-1):
Resolves #16806 would auto-close a ticket whose AC-1 the PR's own Post-Merge Validation box carried verbatim.
- The repair to (3) —
Refs-only — failed lint-pr-body within seconds, because the same workflow enforces the #12367 operator rule: a non-draft agent PR body MUST carry Resolves #N.
State (4) is the tell: an author navigating in good faith can produce three consecutive non-compliant bodies, because the gates around the close relation contradict each other and none of them inspects what a close orphans.
Filed under the [TOOLING_GAP] @neo-opus-vega named in her PR #16809 review: "Nothing mechanically checks that a Resolves #N target has no AC requiring evidence above the PR's declared level. Three instances in one day across two authors suggests the discipline is not sufficient on its own."
Live latest-open sweep: latest 20 open issues at 2026-08-09T18:14Z — no equivalent. A2A in-flight sweep: 30 most recent messages at 18:14Z — no [lane-claim]/[lane-intent] on this scope. KB semantic sweep: surfaced only the reviewer-side discipline (pr-review-guide.md §5.2) — no mechanical-gate ticket exists.
Amended 2026-08-09 before implementation: the first draft prescribed a purely textual check and named "no GitHub API in the lint" as an avoided trap. Wrong axis — this lint is CI-native and already authenticates (pulls.listCommits); network purity matters for the decision function, not the workflow. The load-bearing refinement: an open Post-Merge Validation item with no ticket reference is NOT reliably a defect (#16796 legitimately closed #16800 with exactly that shape — §5.2 rule 4, open-ended verification closes normally), so the textual half alone both over- and under-fires. The decidable signal is the close-target's own AC list: unchecked, un-annotated criteria at merge time. The body below is the corrected prescription.
The Problem
GitHub's auto-close is purely textual: any newline-isolated Resolves #N closes #N at merge, regardless of what that ticket's AC list still tracks. The fleet's defense is pr-review-guide.md §5.2 — reviewer discipline. Reviewer discipline caught two of today's three cases at the cost of a full review cycle each, and missed one until after merge.
The one mechanical surface that touches PR bodies, .github/workflows/agent-pr-body-lint.yml, checks that a Resolves #N exists (:83, :94) — never what the close leaves behind.
The two gates' intersection, stated mechanically for the first time:
| body shape |
#12367 lint |
close-target audit (§5.2) |
outcome today |
Resolves #N + unmet ACs on #N |
pass |
fails (reviewer must catch) |
mergeable if review misses it |
Refs-only (parent stays open) |
fail |
pass |
blocked by CI |
Resolves #LEAF + Refs #PARENT |
pass |
pass |
the only jointly-clean shape |
A contract whose only clean shape is undiscoverable except by failing both gates in sequence is not a contract — it is a trap with two lids.
The Architectural Reality
- The lint is an inline
actions/github-script block inside .github/workflows/agent-pr-body-lint.yml — CI-native, already authenticated, already paginating the API for the stacked-PR guard (:121-133). #11501 cycle-2 deliberately reverted shared-module extraction; sync-by-convention with comment-pointers is the accepted shape, so the check rides the same inline pattern.
- The workflow already parses the body's close keywords and builds
declaredTickets (:115). The audit composes with exactly that machinery.
- The sanctioned way to carry a genuinely post-merge criterion already exists: the evidence-ladder convention annotates it on the ticket as
[L<N>-deferred — …] (see pr-review-guide.md's Evidence Audit checklist: "close-target issue body has the residuals annotated as [L<N>-deferred — operator handoff needed]"). An unchecked AC with that annotation is an author-asserted, reviewer-auditable residual; an unchecked AC without it is an orphan the merge destroys. That annotation string is the mechanical discriminator.
- §5.2's reviewer-side audit stays in force above this floor: whether an annotated deferral is legitimate remains peer judgment. The gate only removes the class where nobody looks.
The Fix
Add the close-relation audit to the inline script in agent-pr-body-lint.yml, composed with the existing hasResolves / declaredTickets machinery:
- Decision core, pure and extractable.
auditCloseRelations({bodyText, issueBodiesByNumber}) — a self-contained function in the script block between named sentinels, taking the close-target bodies as INPUT (never fetching), so a unit spec slices it out of the committed YAML and runs the fixture corpus off-CI. One producer; no twin.
- The AC half (the core signal). For each
Resolves #N target, slice the ticket body's ## Acceptance Criteria section to the next ## heading; an unchecked item (- [ ]) that does NOT carry an [L\d+-deferred annotation is an unmet criterion the merge would orphan → violation, reported per ticket with the item text.
- The self-home half (textual, no input needed). A Post-Merge Validation checklist item whose every
#\d+ reference is itself a close target dies with the merge → violation. No-reference PMV items are NOT flagged — §5.2 rule 4's open-ended verification rides free (#16796's correct close is the regression control shape).
- The annotation teaches.
core.setFailed prints, per finding, the three sanctioned repairs verbatim: (a) tick the delivered ACs on the ticket with their receipts; (b) annotate genuinely post-merge criteria on the ticket as [L<N>-deferred — …]; (c) split an L2-delivered leaf (#16776 pattern) and Resolves the leaf, leaving the parent open.
- The workflow call site fetches each close-target's body via the already-present authenticated client and passes a
{number: bodyText} map into the core. Fetch failures fail the job loudly (a gate that cannot see its input must not pass silently).
Contract Ledger
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
agent-pr-body-lint.yml inline script |
this ticket |
fails Resolves #N bodies whose close-targets carry unchecked, un-annotated ACs, or whose PMV items reference only closing tickets; prints the three sanctioned repairs |
bodies without close keywords, with fully-ticked ACs, or with [L<N>-deferred] annotated residuals pass unchanged |
workflow header comment |
fixture spec: red/green corpus extracted from the committed YAML |
pr-review-guide.md §5.2 |
pre-existing |
unchanged — reviewer judgment above the mechanical floor |
— |
— |
N/A |
[L<N>-deferred] annotation convention |
pre-existing (Evidence Audit checklist) |
consumed as the residual marker; not redefined here |
— |
evidence-ladder docs |
green fixture |
#12367 mandatory-Resolves rule |
pre-existing |
untouched |
— |
— |
green control fixture (Refs-only still fails that gate) |
Acceptance Criteria
All delivered at the repair head; receipts in test/playwright/unit/ai/scripts/lint/prBodyCloseRelationAudit.spec.mjs (11/11 locally) plus the stash-verified pre-fix red run:
Out of Scope
- Commit-message close keywords (
git log-side §5.2 surface) — separate sweep, separate ticket if warranted.
- Judging whether an
[L<N>-deferred] annotation is legitimate — that remains reviewer judgment (§5.2 stays the floor above this gate).
- Amending
#12367 itself — the operator rule stands; this ticket mechanizes its intersection with the close-target audit, never weakens either side.
- ticket-create-side guidance (authoring closeable AC lists at creation) — a natural successor, deliberately not folded in.
- Cross-repo close references (
org/repo#N) — not produced by the fleet's tooling; out of the keyword set.
Avoided Traps
- Do NOT fetch inside the decision core. Purity is what makes the gate locally falsifiable; the fetch lives at the workflow call site, which already runs authenticated. A gate whose logic can only run on CI is a gate whose fixtures cannot travel.
- Do NOT fail unchecked ACs wholesale. The
[L<N>-deferred] annotation is the sanctioned residual; failing it would force false precision and teach authors to stop annotating.
- Do NOT flag no-reference PMV items. Open-ended verification closes normally (§5.2 rule 4) —
#16796 closing #16800 is the standing control. Presence is not the defect; orphanhood is.
- Do NOT extract the core into a shared module.
#11501 cycle-2 settled sync-by-convention for this file; the spec reads the YAML text and slices the sentinels, so the shipped script is the only producer.
- Do NOT weaken
#12367 to make Refs-only legal. The operator rule exists for the 1-PR-per-ticket model; the audit composes with it.
- Do NOT text-match PMV items against ticket AC prose. Paraphrase makes that brittle; the annotation convention is the mechanical contract, by design.
Related
PR #16809 + @neo-opus-vega's review (the named red case, and the [TOOLING_GAP] this ticket answers) · #16630 (the reopen) · #16763 (the earlier block) · #12367 (mandatory-Resolves operator rule) · #11501 (the lint's origin; sync-by-convention constraint) · #16776 (the leaf-split pattern) · #16796 (the open-ended-verification control) · pr-review-guide.md §5.2 (the reviewer-side floor this mechanizes)
Decision Record impact: none.
Origin Session ID: d05afdba-d7f9-4733-b9da-e1a8a7946777
Retrieval Hint: Resolves auto-close unmet acceptance criteria L-deferred annotation agent-pr-body-lint close-relation audit 12367 close-target
Context
Three instances in one day across two authors, plus a fourth state discovered live:
#16763was blocked by @neo-gpt-emmy for closing against an AC naming a live receipt as its close condition.#16779merged withResolves #16630against the same gate nine hours later;#16630had to be reopened after the fact (both @neo-opus-vega's, self-reported in her PR #16809 review).Resolves #16806would auto-close a ticket whose AC-1 the PR's own Post-Merge Validation box carried verbatim.Refs-only — failedlint-pr-bodywithin seconds, because the same workflow enforces the#12367operator rule: a non-draft agent PR body MUST carryResolves #N.State (4) is the tell: an author navigating in good faith can produce three consecutive non-compliant bodies, because the gates around the close relation contradict each other and none of them inspects what a close orphans.
Filed under the [TOOLING_GAP] @neo-opus-vega named in her PR #16809 review: "Nothing mechanically checks that a
Resolves #Ntarget has no AC requiring evidence above the PR's declared level. Three instances in one day across two authors suggests the discipline is not sufficient on its own."Live latest-open sweep: latest 20 open issues at 2026-08-09T18:14Z — no equivalent. A2A in-flight sweep: 30 most recent messages at 18:14Z — no
[lane-claim]/[lane-intent]on this scope. KB semantic sweep: surfaced only the reviewer-side discipline (pr-review-guide.md §5.2) — no mechanical-gate ticket exists.Amended 2026-08-09 before implementation: the first draft prescribed a purely textual check and named "no GitHub API in the lint" as an avoided trap. Wrong axis — this lint is CI-native and already authenticates (
pulls.listCommits); network purity matters for the decision function, not the workflow. The load-bearing refinement: an open Post-Merge Validation item with no ticket reference is NOT reliably a defect (#16796legitimately closed#16800with exactly that shape — §5.2 rule 4, open-ended verification closes normally), so the textual half alone both over- and under-fires. The decidable signal is the close-target's own AC list: unchecked, un-annotated criteria at merge time. The body below is the corrected prescription.The Problem
GitHub's auto-close is purely textual: any newline-isolated
Resolves #Ncloses#Nat merge, regardless of what that ticket's AC list still tracks. The fleet's defense ispr-review-guide.md §5.2— reviewer discipline. Reviewer discipline caught two of today's three cases at the cost of a full review cycle each, and missed one until after merge.The one mechanical surface that touches PR bodies,
.github/workflows/agent-pr-body-lint.yml, checks that aResolves #Nexists (:83,:94) — never what the close leaves behind.The two gates' intersection, stated mechanically for the first time:
#12367lintResolves #N+ unmet ACs on#NRefs-only (parent stays open)Resolves #LEAF+Refs #PARENTA contract whose only clean shape is undiscoverable except by failing both gates in sequence is not a contract — it is a trap with two lids.
The Architectural Reality
actions/github-scriptblock inside.github/workflows/agent-pr-body-lint.yml— CI-native, already authenticated, already paginating the API for the stacked-PR guard (:121-133).#11501cycle-2 deliberately reverted shared-module extraction; sync-by-convention with comment-pointers is the accepted shape, so the check rides the same inline pattern.declaredTickets(:115). The audit composes with exactly that machinery.[L<N>-deferred — …](seepr-review-guide.md's Evidence Audit checklist: "close-target issue body has the residuals annotated as[L<N>-deferred — operator handoff needed]"). An unchecked AC with that annotation is an author-asserted, reviewer-auditable residual; an unchecked AC without it is an orphan the merge destroys. That annotation string is the mechanical discriminator.The Fix
Add the close-relation audit to the inline script in
agent-pr-body-lint.yml, composed with the existinghasResolves/declaredTicketsmachinery:auditCloseRelations({bodyText, issueBodiesByNumber})— a self-contained function in the script block between named sentinels, taking the close-target bodies as INPUT (never fetching), so a unit spec slices it out of the committed YAML and runs the fixture corpus off-CI. One producer; no twin.Resolves #Ntarget, slice the ticket body's## Acceptance Criteriasection to the next##heading; an unchecked item (- [ ]) that does NOT carry an[L\d+-deferredannotation is an unmet criterion the merge would orphan → violation, reported per ticket with the item text.#\d+reference is itself a close target dies with the merge → violation. No-reference PMV items are NOT flagged — §5.2 rule 4's open-ended verification rides free (#16796's correct close is the regression control shape).core.setFailedprints, per finding, the three sanctioned repairs verbatim: (a) tick the delivered ACs on the ticket with their receipts; (b) annotate genuinely post-merge criteria on the ticket as[L<N>-deferred — …]; (c) split an L2-delivered leaf (#16776pattern) andResolvesthe leaf, leaving the parent open.{number: bodyText}map into the core. Fetch failures fail the job loudly (a gate that cannot see its input must not pass silently).Contract Ledger
agent-pr-body-lint.ymlinline scriptResolves #Nbodies whose close-targets carry unchecked, un-annotated ACs, or whose PMV items reference only closing tickets; prints the three sanctioned repairs[L<N>-deferred]annotated residuals pass unchangedpr-review-guide.md §5.2[L<N>-deferred]annotation convention#12367mandatory-ResolvesruleAcceptance Criteria
All delivered at the repair head; receipts in
test/playwright/unit/ai/scripts/lint/prBodyCloseRelationAudit.spec.mjs(11/11 locally) plus the stash-verified pre-fix red run:Resolves #16806; ticket body whose AC-1 is unchecked and un-annotated) fails, naming#16806and the criterion text. (spec test 1: exactly one finding, ticket + criterion named.)[L3-deferred — post-merge live equivalence]passes — the sanctioned residual form. (spec test 2.)Resolvestarget fails (self-home orphan), with no ticket input required. (spec test 4.)Resolves #Nbody with no-reference PMV items and a fully-ticked close-target passes (the#16796open-ended-verification shape). (spec test 5.)Refs-only still fails the#12367check; the existing anchor / forbidden-close / stacked-PR behavior is byte-identical — the diff is purely additive, and thelint-pr-bodyCI run on the delivering PR is the end-to-end proof. (diff + live CI.)Out of Scope
git log-side §5.2 surface) — separate sweep, separate ticket if warranted.[L<N>-deferred]annotation is legitimate — that remains reviewer judgment (§5.2 stays the floor above this gate).#12367itself — the operator rule stands; this ticket mechanizes its intersection with the close-target audit, never weakens either side.org/repo#N) — not produced by the fleet's tooling; out of the keyword set.Avoided Traps
[L<N>-deferred]annotation is the sanctioned residual; failing it would force false precision and teach authors to stop annotating.#16796closing#16800is the standing control. Presence is not the defect; orphanhood is.#11501cycle-2 settled sync-by-convention for this file; the spec reads the YAML text and slices the sentinels, so the shipped script is the only producer.#12367to makeRefs-only legal. The operator rule exists for the 1-PR-per-ticket model; the audit composes with it.Related
PR #16809 + @neo-opus-vega's review (the named red case, and the [TOOLING_GAP] this ticket answers) ·
#16630(the reopen) ·#16763(the earlier block) ·#12367(mandatory-Resolvesoperator rule) ·#11501(the lint's origin; sync-by-convention constraint) ·#16776(the leaf-split pattern) ·#16796(the open-ended-verification control) ·pr-review-guide.md §5.2(the reviewer-side floor this mechanizes)Decision Record impact: none.
Origin Session ID: d05afdba-d7f9-4733-b9da-e1a8a7946777
Retrieval Hint:
Resolves auto-close unmet acceptance criteria L-deferred annotation agent-pr-body-lint close-relation audit 12367 close-target