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:61 — RESIDUAL_OWNER_LINE_PATTERN, shape-only.
…:67 — RESIDUAL_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 state ∈ open | 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
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")
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: #17271on that PR, parking three live-plane observations the sandbox cannot make. #17271 had closed at 17:55:42Z — eight minutes earlier. Thelint-pr-bodygate 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:42Zvia 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]*$/imThe validator is careful in every dimension except existence. It already:
:58-59);Residual-Owner: #200documents the spelling rather than declaring an owner (:300,:439-441);find()let a second owing section ride on the first's owner (:427-431);: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:61—RESIDUAL_OWNER_LINE_PATTERN, shape-only.…:67—RESIDUAL_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. Runsactions/checkoutand has network +GITHUB_TOKEN; the same validator also runs locally vianpm 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: #Nagainst 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: #Nis 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 checksexits1for 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.
resolveIssueState(number, opts)(new export)ai/scripts/agent-preflight.mjs{isPullRequest, state}withstate∈open|closed|missing|unknown.missingonly on(HTTP 404)in stderr; every other failure — 401/403/503/DNS/ENOENT/timeout/unparseable payload — isunknownunknown; never throwsexecFileSynccarriestimeout: 5000ms, overridable viatimeoutMs; the kill maps tounknownGH_PROBE_TIMEOUT_MSunknownpull_requestis carried, never collapsed: an open PR and an open ticket are the samestatestring and different answersisPullRequest: falsewhen unread--jqprojection pinned to containhas("pull_request")validatePrBody(body, options)resolveOwnerState— absent by default, so the validator stays pure, synchronous and offlinewarnings: String[];validstill derives frommissingVisible/missingInvisibleonly[]@returnsvalidcollectDeclaredResidualOwnersagent-preflightCLI stderrAcceptance Criteria
Residual-Owner: #Nnaming a closed ticket fails the gate, with the closed state named in the message.Residual-Owner: #Nnaming a nonexistent issue number fails the gate.Residual-Owner: #Nnaming an open ticket passes — the control, so the check cannot pass by refusing everything.Residual: …, Residual-Owner: #N) gets the same treatment as the standalone line form.state: openas 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.Residual-Owner: #17271with that ticket closed reproduces the failure; the pre-fix run passes silently.Out of Scope
Validating
Resolves #N/Refs #N/Related: #Ntargets 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
npm run agent-preflightis the author's offline pre-flight and its value is that it runs anywhere. A check that needs GitHub must degrade, not block.gh pr list --state openreturned a MERGED PR as open whilegh pr viewreportedMERGED. A search-backed check would inherit that lag.Related
PR #17308 (the live specimen — its
## Evolutionsection 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")