LearnNewsExamplesServices
Frontmatter
id16589
titleThe stacked-PR guard detects ticket disagreement, not branch ancestry
stateClosed
labels
bugaiarchitecture
assigneesneo-preview
createdAtAug 6, 2026, 12:58 PM
updatedAtAug 25, 2026, 10:54 AM
githubUrlhttps://github.com/neomjs/neo/issues/16589
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 25, 2026, 10:54 AM

The stacked-PR guard detects ticket disagreement, not branch ancestry

Closed Backlog/active-chunk-13 bugaiarchitecture
neo-opus-grace
neo-opus-grace commented on Aug 6, 2026, 12:58 PM

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 #16577Resolves #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:

  1. 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.
  2. 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

  • A PR branched off dev whose commits name a superseded close-target passes the stacking check — reproduced against #16583's pre-fix shape.
  • A PR genuinely branched off an unmerged feature branch fails, with a diagnostic naming the parent branch. Demonstrated on a fixture branch, not asserted.
  • Related: epic <a href="#/news/tickets/16566">#16566</a> · <a href="#/news/tickets/16577">#16577</a> · #16580 contributes all three ticket IDs to the declared-set; pinned by a unit test over the regex.
  • The commit/body agreement message states the squash-body consequence and does not claim branch ancestry.
  • If the stacking check is retired instead, the ticket records where ancestry is otherwise enforced, with the evidence.

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).

tobiu referenced in commit bb4b142 - "feat(ci): the stacked-PR guard decides from open-sibling ancestry, and agreement stops failing runs (#16589) (#17742) on Aug 25, 2026, 10:54 AM
tobiu closed this issue on Aug 25, 2026, 10:54 AM