Context
The stacked-PR guard (#15352) exists to catch a PR branched off another feature branch rather than dev. Its docblock is explicit about why that matters: such a PR is "INVISIBLE to every other gate" — the file diff is computed from the merge-base so the reviewed surface looks correct, CI is green, and the body lint's own Resolves check passes.
It does not check branch ancestry. It checks whether every commit's trailing (#K) is a ticket the body declares, and uses that as a proxy.
Three live observations from 2026-08-06 show the proxy failing in both directions, and a coupling that makes one failure mode load-bearing.
The Problem
1. False positive — it fired on a PR branched off dev
PR #16583 was branched off dev. Not stacked. It failed the guard because its author repointed the close-target mid-flight (Resolves #16577 → Resolves #16587) while two earlier commits still carried (#16577):
PR #16583 carries 2 foreign commit(s) — 9376a4ac7d (#16577), fcb3854f27 (#16577).
Body declares #16587. Likely branched off a feature branch, not dev.
The diagnostic's stated conclusion — "likely branched off a feature branch" — was wrong. Repointing a close-target is a normal, encouraged outcome of review, and it produces this signature every time.
2. False negative — the house Related: convention declares nothing
const declaredTickets = new Set(
[...body.matchAll(/\b(?:Resolves|Refs|Related):?\s+#(\d+)/gi)].map(m => m[1])
);
# must follow the keyword immediately. Our convention is:
Related: epic #16566 · #16577 (…) · #16580 · #16581
epic sits between Related: and #16566, so that line contributes zero entries — and the regex captures only the first number after a keyword anyway, so even Related: #16566 · #16577 would declare one of four. In practice the declared-set is usually just the single Resolves target, so a genuinely stacked PR carrying commits for any other declared ticket passes silently.
3. The coupling that makes this load-bearing
PR #16578 merged the same day with the identical mismatch — commits stamped (#16577), body declaring Resolves #16580 — and passed. The only reason: its body still contained a backticked `Resolves #16577` in review-response prose, which the regex counts.
So on #16583 the author removed that exact string to prevent an unwanted auto-close, and thereby turned the guard red. One literal was doing both jobs — satisfying the guard and risking the close — and neither mechanism references the other. A correct fix to one silently broke the other.
The Architectural Reality
The property the guard wants is branch ancestry, and it is directly observable rather than inferable:
NEO_CODE_BLOCK_3
A PR branched off dev has a merge-base that is an ancestor of dev. A PR branched off an unmerged feature branch does not. No proxy, no false positives from close-target repointing, no dependence on prose formatting.
The commit-message half is a separate concern, and it is not cosmetic. Measured on origin/dev:
NEO_CODE_BLOCK_4
GitHub's default squash body is the concatenated commit subjects, so intermediate ticket IDs do land in dev permanently. git log --grep='#16577' on dev returns a commit that delivered #16580, and 8f9e804f75 carries the epic number that §5.2 forbids as a close-target. The common assumption — "squash only lands the title, so branch history does not matter" — is false for provenance, and both of those merged because a reviewer (me) accepted it.
That is a real concern, but it is commit/body agreement, not stacking. Conflating the two is what produced a single check with two purposes and a wrong diagnostic message.
The Fix
Split the two properties the current check conflates:
- Stacking — replace the ticket proxy with a merge-base ancestry test. Fail when the PR's merge-base is not an ancestor of the base branch, with a diagnostic naming the actual parent branch.
- Commit/body ticket agreement — keep it, but as its own check with its own message ("commit
<sha> claims #K; this PR delivers #M — the squash body will carry #K into dev"), and fix the declared-set regex so the Related: epic #N · #M · … convention parses: scan all #\d+ following the keyword to end-of-line, not just the first token.
Or retire (1) entirely. If merge-base ancestry is already enforced elsewhere, the stacking half is redundant and only (2) needs to survive. Deciding that is part of this ticket — the current state, where a proxy with failures in both directions carries a confident wrong diagnostic, is what must not persist.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
agent-pr-body-lint.yml stacking check |
this ticket |
Merge-base ancestry test, or removed |
— |
inline docblock |
#16583 false positive |
agent-pr-body-lint.yml declared-set regex |
:115-117 |
Parses all #N after the keyword to end-of-line |
Current single-capture |
inline docblock |
Related: epic <a href="#/news/tickets/16566">#16566</a> · #16577 declares 0 |
| commit/body agreement diagnostic |
new, split from the above |
Names the squash-body consequence, not branch ancestry |
— |
inline docblock |
318e6cb990, 8f9e804f75 on dev |
Decision Record impact
none. A CI-gate correction; no runtime authority changes.
Acceptance Criteria
Out of Scope
- Rewriting
dev history for 318e6cb990 / 8f9e804f75. They are landed; the cost is one stale reference each and it is not worth a history rewrite.
- The auto-close/backtick interaction itself. GitHub's parser is not ours to change; the coupling is recorded here as the reason the false negative mattered, not as a fix target.
- Any change to
Resolves-mandatory policy (#12367).
Avoided Traps
"Just require every commit to name the close-target." That forces a rebase on every review-driven repoint — exactly the cost the author of #16583 correctly pushed back on before the gate overruled them. The squash-body concern is real but is served by a warning that names it, not by mandatory history rewrites.
"Relax the guard because we squash-merge." Half right and the half that is wrong is measurable: squash collapses content, but the concatenated body carries provenance into dev verbatim. Two commits landed today with ticket IDs they did not deliver, one of them an epic.
Related
- #15352 — the guard this corrects.
- #16157 (closed) —
agent-preflight missed stacked-PR foreign-ticket declarations; adjacent, different surface.
- #15828 (closed) — prior correction to this same workflow's diagnostics.
- PR #16583 / PR #16578 / PR #16579 — the false positive and the two false negatives.
Handoff Retrieval Hints
query_raw_memories: "stacked PR guard declared tickets regex merge-base ancestry squash body provenance"
- Source anchors:
.github/workflows/agent-pr-body-lint.yml:100-135 (guard), :115-117 (declared-set regex).
- Live latest-open sweep: checked the latest 20 open issues 2026-08-06;
resources/content/issues/ keyword sweep returned only closed origin tickets. No equivalent found.
Origin Session ID: 8921d480-6087-4bfa-abe0-4f47873e06c4
Authored by @neo-opus-grace (Claude Opus 5).
Context
The stacked-PR guard (#15352) exists to catch a PR branched off another feature branch rather than
dev. Its docblock is explicit about why that matters: such a PR is "INVISIBLE to every other gate" — the file diff is computed from the merge-base so the reviewed surface looks correct, CI is green, and the body lint's ownResolvescheck passes.It does not check branch ancestry. It checks whether every commit's trailing
(#K)is a ticket the body declares, and uses that as a proxy.Three live observations from 2026-08-06 show the proxy failing in both directions, and a coupling that makes one failure mode load-bearing.
The Problem
1. False positive — it fired on a PR branched off
devPR #16583 was branched off
dev. Not stacked. It failed the guard because its author repointed the close-target mid-flight (Resolves #16577→Resolves #16587) while two earlier commits still carried(#16577):The diagnostic's stated conclusion — "likely branched off a feature branch" — was wrong. Repointing a close-target is a normal, encouraged outcome of review, and it produces this signature every time.
2. False negative — the house
Related:convention declares nothingconst declaredTickets = new Set( [...body.matchAll(/\b(?:Resolves|Refs|Related):?\s+#(\d+)/gi)].map(m => m[1]) );#must follow the keyword immediately. Our convention is:epicsits betweenRelated:and#16566, so that line contributes zero entries — and the regex captures only the first number after a keyword anyway, so evenRelated: #16566 · #16577would declare one of four. In practice the declared-set is usually just the singleResolvestarget, so a genuinely stacked PR carrying commits for any other declared ticket passes silently.3. The coupling that makes this load-bearing
PR #16578 merged the same day with the identical mismatch — commits stamped
(#16577), body declaringResolves #16580— and passed. The only reason: its body still contained a backticked`Resolves #16577`in review-response prose, which the regex counts.So on #16583 the author removed that exact string to prevent an unwanted auto-close, and thereby turned the guard red. One literal was doing both jobs — satisfying the guard and risking the close — and neither mechanism references the other. A correct fix to one silently broke the other.
The Architectural Reality
The property the guard wants is branch ancestry, and it is directly observable rather than inferable:
NEO_CODE_BLOCK_3
A PR branched off
devhas a merge-base that is an ancestor ofdev. A PR branched off an unmerged feature branch does not. No proxy, no false positives from close-target repointing, no dependence on prose formatting.The commit-message half is a separate concern, and it is not cosmetic. Measured on
origin/dev:NEO_CODE_BLOCK_4
GitHub's default squash body is the concatenated commit subjects, so intermediate ticket IDs do land in
devpermanently.git log --grep='#16577'ondevreturns a commit that delivered #16580, and8f9e804f75carries the epic number that §5.2 forbids as a close-target. The common assumption — "squash only lands the title, so branch history does not matter" — is false for provenance, and both of those merged because a reviewer (me) accepted it.That is a real concern, but it is commit/body agreement, not stacking. Conflating the two is what produced a single check with two purposes and a wrong diagnostic message.
The Fix
Split the two properties the current check conflates:
<sha>claims #K; this PR delivers #M — the squash body will carry #K intodev"), and fix the declared-set regex so theRelated: epic #N · #M · …convention parses: scan all#\d+following the keyword to end-of-line, not just the first token.Or retire (1) entirely. If merge-base ancestry is already enforced elsewhere, the stacking half is redundant and only (2) needs to survive. Deciding that is part of this ticket — the current state, where a proxy with failures in both directions carries a confident wrong diagnostic, is what must not persist.
Contract Ledger Matrix
agent-pr-body-lint.ymlstacking checkagent-pr-body-lint.ymldeclared-set regex:115-117#Nafter the keyword to end-of-lineRelated: epic <a href="#/news/tickets/16566">#16566</a> · #16577declares 0318e6cb990,8f9e804f75ondevDecision Record impact
none. A CI-gate correction; no runtime authority changes.Acceptance Criteria
devwhose commits name a superseded close-target passes the stacking check — reproduced against #16583's pre-fix shape.Related: epic <a href="#/news/tickets/16566">#16566</a> · <a href="#/news/tickets/16577">#16577</a> · #16580contributes all three ticket IDs to the declared-set; pinned by a unit test over the regex.Out of Scope
devhistory for318e6cb990/8f9e804f75. They are landed; the cost is one stale reference each and it is not worth a history rewrite.Resolves-mandatory policy (#12367).Avoided Traps
"Just require every commit to name the close-target." That forces a rebase on every review-driven repoint — exactly the cost the author of #16583 correctly pushed back on before the gate overruled them. The squash-body concern is real but is served by a warning that names it, not by mandatory history rewrites.
"Relax the guard because we squash-merge." Half right and the half that is wrong is measurable: squash collapses content, but the concatenated body carries provenance into
devverbatim. Two commits landed today with ticket IDs they did not deliver, one of them an epic.Related
agent-preflightmissed stacked-PR foreign-ticket declarations; adjacent, different surface.Handoff Retrieval Hints
query_raw_memories: "stacked PR guard declared tickets regex merge-base ancestry squash body provenance".github/workflows/agent-pr-body-lint.yml:100-135(guard),:115-117(declared-set regex).resources/content/issues/keyword sweep returned only closed origin tickets. No equivalent found.Origin Session ID: 8921d480-6087-4bfa-abe0-4f47873e06c4
Authored by @neo-opus-grace (Claude Opus 5).