LearnNewsExamplesServices
Frontmatter
id17314
titleResidual-Owner validation matches the ticket''s shape, never its state
stateClosed
labels
bugaiarchitecture
assigneesneo-opus-ada
createdAtAug 17, 2026, 8:50 PM
updatedAtAug 21, 2026, 9:38 PM
githubUrlhttps://github.com/neomjs/neo/issues/17314
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 21, 2026, 9:38 PM

Residual-Owner validation matches the ticket's shape, never its state

Closed Backlog/active-chunk-17 bugaiarchitecture
neo-opus-grace
neo-opus-grace commented on Aug 17, 2026, 8:50 PM

Residual-Owner validation matches the ticket's shape, never its state

Context

Caught live on PR #17308 today (2026-08-17). At 18:04 I set Residual-Owner: #17271 on that PR, parking three live-plane observations the sandbox cannot make. #17271 had closed at 17:55:42Z — eight minutes earlier. The lint-pr-body gate passed the line.

The PR body also carried a paragraph arguing #17271 was the right home because its AC4 was still open. That sentence was true when written and false when published; nothing between writing and merging would have caught it.

Observation vs inference separated: the timestamps, the passing gate, and the closed state are observed (closed_at: 2026-08-17T17:55:42Z via object read; gate verdict from the workflow run). The mechanism below is code-read, cited by line.

The Problem

ai/scripts/agent-preflight.mjs:61:

RESIDUAL_OWNER_LINE_PATTERN = /^[ \t]*(?:>[ \t]*)*(?:[-*+][ \t]+)?Residual-Owner:[ \t]+#(\d+)[ \t]*$/im

The validator is careful in every dimension except existence. It already:

  • anchors the declaration to its own line, so prose cannot discharge an obligation (:58-59);
  • blanks inline code, so a backticked Residual-Owner: #200 documents the spelling rather than declaring an owner (:300, :439-441);
  • checks every owing Post-Merge Validation section rather than the first, after a find() let a second owing section ride on the first's owner (:427-431);
  • refuses the close target as an owner, because "parking it on the close target is the one destination guaranteed to be unreachable the moment it becomes actionable — measured across four merged PRs whose close targets shut within a second of the merge" (:413-417).

That last comment states the gate's whole purpose: deferred work must name a home that survives the merge. A ticket that closed before the citation fails that requirement more completely than the close-target case the gate already blocks — the close target at least survives until merge.

#(\d+) cannot see any of it. The gate validates that a reference is well-formed, and treats well-formed as alive.

The Architectural Reality

  • ai/scripts/agent-preflight.mjs:61RESIDUAL_OWNER_LINE_PATTERN, shape-only.
  • …:67RESIDUAL_OWNER_INLINE_PATTERN, the Evidence-ladder one-line form. Same blind spot.
  • …:413-420 — the rationale comment. The gate's own stated intent is survivability, which is a state property.
  • …:448-460 — the obligation branch, where the owner is currently accepted on match alone.
  • .github/workflows/agent-pr-body-lint.yml — the CI arm. Runs actions/checkout and has network + GITHUB_TOKEN; the same validator also runs locally via npm run agent-preflight, which may be offline. This asymmetry is the design constraint, not an afterthought — see Avoided Traps.

The close-target exclusion is already a semantic check (it compares the owner against Resolves #N), so the validator is not purely syntactic today. What it lacks is any read of the cited ticket itself.

The Fix

Resolve the cited Residual-Owner: #N against live GitHub and fail when the owner is closed or missing. Both the line form and the Evidence-ladder inline form.

The failure message should name the state it found, in the shape the existing messages use — they prescribe a remedy rather than only reporting: "Residual-Owner: #N is CLOSED (closed_at: …). Deferred work must name a home that survives the merge. Finish it, drop the obligation, or name an open ticket. Do not open a ticket to satisfy this."

Offline behaviour is the real design decision, and it belongs to the implementer with the constraint stated: the local run must not become network-dependent, and a network failure must not manufacture a red. The honest default is that an unresolvable read is unknown — it does not pass the owner and does not fail the gate on the transport's behalf. Whether that means skipping the check locally while enforcing it in CI, or a soft-warn/hard-fail split, is the call to make at implementation time. Today's session produced the anti-pattern first-hand: an instrument that reads a 503 as a verdict manufactures the diagnosis (gh pr checks exits 1 for both a failing check and an unreachable API, which had me one step from reporting a fully green board as red — three separate times).

Contract Ledger Matrix

Backfilled after implementation (@neo-gpt-emmy's RA-4 on PR #17488): the gate's own consumed surfaces were never declared, which is the shape this ticket is about — a contract asserted rather than recorded.

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
resolveIssueState(number, opts) (new export) ai/scripts/agent-preflight.mjs Returns {isPullRequest, state} with stateopen | closed | missing | unknown. missing only on (HTTP 404) in stderr; every other failure — 401/403/503/DNS/ENOENT/timeout/unparseable payload — is unknown unknown; never throws function docblock live reads: #17314 → open-issue, #17442 → closed, #17488 → open-PR, 99999999 → missing
— its deadline same execFileSync carries timeout: 5000 ms, overridable via timeoutMs; the kill maps to unknown unchanged 5 s default docblock on GH_PROBE_TIMEOUT_MS an arm asserts the option is finite and that a kill yields unknown
— entity kind GitHub Issues REST pull_request is carried, never collapsed: an open PR and an open ticket are the same state string and different answers isPullRequest: false when unread docblock --jq projection pinned to contain has("pull_request")
validatePrBody(body, options) same New optional resolveOwnerState — absent by default, so the validator stays pure, synchronous and offline no resolver ⇒ shape-only, exactly today's behaviour docblock every pre-existing arm unchanged
— its return same Adds warnings: String[]; valid still derives from missingVisible/missingInvisible only [] docblock @returns a warning never changes valid
— owner cardinality same Every declared owner is state-checked (all owing PMV sections + the inline Evidence form), deduplicated, close target excluded shape reporting still names one section, unchanged docblock on collectDeclaredResidualOwners a resolver call ledger proves both owners inspected
A PR-shaped owner this gate An open pull request FAILS: it closes on merge and takes the deferral with it failure message open-issue and open-PR controls, asserted apart
agent-preflight CLI stderr same Warnings print on both the green and red paths, before the verdict silent when there are none a check that did not run is news whether or not the rest passed

Acceptance Criteria

  • A Residual-Owner: #N naming a closed ticket fails the gate, with the closed state named in the message.
  • A Residual-Owner: #N naming a nonexistent issue number fails the gate.
  • A Residual-Owner: #N naming an open ticket passes — the control, so the check cannot pass by refusing everything.
  • The Evidence-ladder inline form (Residual: …, Residual-Owner: #N) gets the same treatment as the standalone line form.
  • An unresolvable GitHub read (offline, 503, rate-limited, missing token) does NOT fail the gate and does NOT silently pass the owner; the chosen disposition is documented at the assertion.
  • Every declared owner is state-checked, not the one section selected for shape reporting. Control: first section's owner open, second section's owner closed — the closed one fails, and a call ledger proves both were read.
  • The live read is deadline-bounded. An unbounded synchronous call is network-dependence regardless of how its failure is classified: the author blocks before the graceful-degradation branch runs.
  • An open pull request fails. A PR is an issue to the REST API and reports the same state: open as a ticket; it is a worse owner than a closed one because it disappears by design. Control: an open ISSUE with the same number still passes.
  • Red-proof against the real incident: a body citing Residual-Owner: #17271 with that ticket closed reproduces the failure; the pre-fix run passes silently.

Out of Scope

Validating Resolves #N / Refs #N / Related: #N targets the same way — plausibly the same class, but a separate decision with a different blast radius · the close-target exclusion, which already works · rate-limit strategy or response caching beyond what the AC above requires · any change to which sections are considered owing.

Avoided Traps

  • Making the local validator network-dependent. npm run agent-preflight is the author's offline pre-flight and its value is that it runs anywhere. A check that needs GitHub must degrade, not block.
  • Treating an unreachable API as a closed owner. This is the exact failure this repo hit today from the other direction — could-not-verify is not did-not-happen, and a gate that converts an outage into a verdict is worse than the hole it closes.
  • Filing a ticket to satisfy the gate. The existing message says "Do not open a ticket to satisfy this"; a state check must not create pressure to mint an owner, so the message keeps prescribing finish-or-drop first.
  • Assuming the search index. Resolve the owner by object read, not by issue search: post-outage today gh pr list --state open returned a MERGED PR as open while gh pr view reported MERGED. A search-backed check would inherit that lag.

Related

PR #17308 (the live specimen — its ## Evolution section records the incident) · #17305 · #17309 (the re-homed owner) · learn/agentos/process/evidence-ladder.md (the Evidence-ladder form that shares the blind spot).

Live latest-open sweep: latest 20 open checked 2026-08-17T18:48:58Z, nearest neighbours #17284 (Round-2 label detector regex) and #17304 (PAT validation outage resilience) — related in flavour, neither equivalent. A2A claim-window sweep over the last 30 messages: no competing claim on this scope.

Origin Session ID: ddbee747-a0f6-41d3-a41e-813561d2d9f9

Retrieval Hint: query_raw_memories("Residual-Owner matches shape never state closed ticket lint gate PR 17308")

tobiu referenced in commit 1f8cce8 - "fix(ai): the Residual-Owner gate reads the ticket, not just the reference (#17314) (#17488) on Aug 21, 2026, 9:38 PM
tobiu closed this issue on Aug 21, 2026, 9:38 PM