On 2026-07-29 I closed a review session by writing, to the operator and to a peer, "#16129, #16133, #16134, #16135 are all merge-eligible." Measured immediately after: #16129 had been merged 2h07m, #161349 min, #161357 min. One of four was actually open.
The habit fix is trivial — look before you write. The interesting part is that the substrate already told me twice and still did not catch it:
post-review-pickup-workflow.md §5 — "PR-state freshness gate", plus an anti-pattern row: "Naming a PR's merge state from a wake payload instead of live gh pr view | Wakes are stale by construction; this is how false merge-eligible claims reach the operator"
So this is not a missing-rule ticket. It is a tooling shape ticket: the instruments make the correct answer more expensive and less informative than the incorrect one.
Separately measured the same day, because it changes the stakes: approve→merge latency across 11 PRs was 6 of 11 under 3m20s, three of those under 41s (28s / 33s / 41s), median ≈3m, with a 1.5–2h tail only while the operator was away. There is effectively no post-approval correction window — a stale board claim is not caught downstream.
The Problem
Three separate defects compound.
1. Field parity gap (verified).list_pull_requests returns only:
§10.1 asks for state, mergedAt, reviewRequests. The tool returns one of the three. So an agent obeying the freshness rule literally cannot satisfy it from the board tool and is pushed to gh pr view <N> — which is per-PR. That is the mechanism by which the board blind spot survives: the rule's fields exist only on the per-PR instrument, so diligent agents check PRs they already know about.
2. An open-only snapshot cannot express a departure. My failure was not a PR appearing — it was three PRs silently leaving. state: 'open' returns current membership; there is no shape in which it reports "the three you believed open are gone." Any design that only lists current members reproduces the bug for a caller who checks.
3. Nothing falsifies the caller's belief. The artifact that goes stale is the agent's own list. Today the agent must fetch a snapshot and diff it against memory by hand — the exact step that gets skipped, and the step where "it was true when I verified it" wins.
ai/services/github-workflow (15 files) owns PR source reads; PullRequestService.mjs is the sibling that already performs multi-field PR derivation.
Direct precedent, merged today: 16126 added get_conversation({projection: 'merge-readiness'}) — an opt-in projection on an existing operation, touching exactly openapi.yaml, toolService.mjs, and PullRequestService.mjs. This ticket is the same shape on a different operation, so the review precedent and the file set are both established.
learn/agentos/GitHubWorkflow.md is currently the only doc naming list_pull_requests, while both freshness gates cite gh pr view. The board instrument and the board rule are documented apart from each other.
No new MCP tool. Tool descriptions load into every consuming agent's context, so a redundant tool spends reasoning budget fleet-wide for zero new capability. Additive projection only.
No server-side state. A remembered per-identity watermark would make two identical calls return different results and reintroduce hidden inputs — the failure 16126 explicitly designed against by deriving everything from the caller's coordinate.
Structure-map gate: npm run --silent ai:structure-map -- --files --loc run at creation time; owning folders and sibling precedent cited above. No new .mjs file, so no structural pre-flight required.
The Fix
1. Field parity — ✅ DELIVERED by #16165 / PR #16170 (merged 2026-07-30T12:09:00Z). This limb is closed and is no longer scope here. mergedAt, reviewDecision, reviewRequests, headRefOid, and mergeStateStatus are live on dev's openapi.yaml, with reviewRequests: null meaning unavailable-or-incomplete and [] meaning fetched-and-empty. Verified 2026-07-30 against the merged PR's file list and the shipped spec, not inferred from the ticket.
2. since — a delta projection. Optional ISO timestamp; response gains a delta block with arrived, moved, and — load-bearing — departed, the transition an open-only query structurally cannot report.
3. believedOpen — let the tool falsify the caller's claim. Optional array of PR numbers the caller believes are open. Response partitions them:
An agent about to write a board summary already holds the list that is about to be wrong. Making that list a parameter converts a habit ("remember to look") into a mechanism ("submit your belief, receive its refutation"). unlisted catches the mirror case — open PRs the caller did not know about. Fully stateless: the response depends only on the request.
4. Echo checkedAt on every response so a caller feeds it back as the next since without minting a timestamp — agents cannot always call Date.now().
5. Fail closed on an unusable since / unresolvable number.delta: {unavailable: '<reason>'} must never render as delta: {arrived: [], moved: [], departed: []}. "Nothing changed" and "I cannot tell you what changed" producing identical output is the single most common way an instrument lies, and it is the defect class behind 16110, 16121's unattributable null result, and the board claim that motivated this ticket.
6. Align the rule text with the instrument. Both freshness gates cite gh pr view <N>; add the board instrument beside it, and widen the post-review-pickup-workflow.md anti-pattern row's stale-source list — it names wake payloads and omits your own earlier verified observation, which is the source that actually bit. A one-clause widening, not a third restatement.
Partitions into stillOpen / falsified (observed terminal state + mergedAt) / unverifiable (explicit reason)
A number that could not be OBSERVED lands in unverifiable, never in falsified — see the correction below; plus a declared batch cap whose overflow is an explicit refusal, never a silent partition of the first N
Operation description
Partition fixture including an unobservable number + an over-cap refusal negative
checkedAt response field
Server clock at read time
Present on every response, usable verbatim as the next since
N/A — always present
Operation description
Round-trip fixture feeding checkedAt back as since
Two amendments, both derived from this ticket's own §"Fail closed" rule rather than from new information. Raised while answering @neo-gpt-emmy's [design-fork]; her proposed leaf had faithfully inherited the defect from this body.
1. unresolvable → falsified was wrong. It is now unverifiable. The original ledger row said an unresolvable number "lands in falsified with an explicit reason." That violates item 5 above, in the direction I had not considered. Item 5 guards against "I cannot tell you what changed" rendering as "nothing changed" — a false confirmation. Folding an inaccessible number into falsified is the mirror image: "I could not observe this" rendering as "your belief was refuted" — a false refutation. That is the more dangerous half, because the caller acts on it: they correct a belief that was in fact true, on the strength of a permissions blip or a transient read failure. falsified must mean an observed terminal state. Anything unobserved is unverifiable, and the two must never share a shape.
2. believedOpen needs a declared cap with a fail-closed overflow. The parameter is caller-supplied and unbounded while the implementation is one batched read against a node/complexity limit. Truncation returns an empty falsified for the unchecked tail, so a caller who submitted 200 numbers and had 40 verified reads "every belief held." A partially-verified belief set is worse than an unverified one, because it arrives wearing the same output as success — item 5 again, at the batch boundary. Overflow must be an explicit refusal or a per-number unverifiable, never a silent partition of the first N.
Scope note: limbs 2 (since / arrived / moved / departed) and the unlisted half of limb 3 are withdrawn from the next leaf, on this ticket's own logic. unlisted requires knowing the complete open set, which a bounded board page cannot supply; a since-delta reconstructed from updatedAt cannot distinguish a quiet board from an unobservable one, which is precisely the instrument item 5 forbids. Any future revival needs caller-supplied coordinates, not timestamp inference. moved needs no revival at all — head staleness is already answerable from the shipped headRefOid by a caller holding a prior value, so it is covered rather than omitted.
| Freshness-gate rule text | pr-review-guide.md §10.1 + post-review-pickup-workflow.md §5 and its anti-pattern table | Names the board instrument beside gh pr view <N>; stale-source list widened to include the caller's own earlier verified observation | N/A | Both payloads | Byte-delta within the per-file +250 oversized-map cap |
Decision Record impact
none — additive projection on an existing MCP operation, no ADR conflict identified. Explicitly aligned with the standing MCP surface-reduction discipline: this adds capability without adding a tool, and the no-server-state constraint aligns with 16126's derive-from-the-coordinate precedent.
Acceptance Criteria
list_pull_requests returns mergedAt, reviewDecision, reviewRequests, headRefOid, and mergeStateStatus; absent values are null rather than omitted keys.
A single list_pull_requests call satisfies everything pr-review-guide.md §10.1 names, for the whole board, without a per-PR follow-up.
since returns delta.departed for a PR that was open at the watermark and is no longer open, including its terminal state and mergedAt.
since returns delta.arrived for a PR opened after the watermark, and delta.moved for one whose head or reviewDecision changed.
An unusable since yields delta.unavailable with a reason and is distinguishable in shape from a delta where nothing changed.
believedOpen partitions into stillOpen / falsified / unlisted, with falsified entries carrying terminal state and merge timestamp.
An unresolvable number in believedOpen appears in falsified with an explicit reason and is never silently dropped.
checkedAt is present on every response and round-trips as a valid since.
No new MCP operation or tool is added; McpServerToolLimits confirms the catalog did not grow.
x-neo-tool-summary stays ≤120 chars and names since / believedOpen; description stays ≤1024.
Both freshness-gate payloads name the board instrument, and the anti-pattern stale-source list includes the caller's own earlier verified observation; combined skill-Markdown delta stays inside the per-file +250 cap without consuming a growth exception.
Default call shape (no since, no believedOpen) is byte-identical in behavior to today's, pinned by a regression assertion.
Out of Scope
Any new MCP tool. The whole point is that list_pull_requests already exists and only needs its shape widened.
Server-side or per-identity watermark persistence. Statelessness is a design constraint, not an omission.
Issue-board equivalents (list_issues). Same argument may apply, but it is a separate surface and a separate ticket.
Changing when agents are required to check. The rule already exists twice; this ticket makes obeying it cheap and informative, and only widens one stale-source clause.
Auto-injecting board state into wake payloads. That would recreate the very cache the freshness gates exist to distrust.
Avoided Traps
A new board_overview tool. Tool descriptions are loaded per-agent-per-turn; a second tool covering an existing one costs fleet-wide reasoning budget for no capability. Extend, do not add.
Server-remembered watermark. Makes two identical calls return different results, reintroduces hidden state, and breaks idempotence — the property 16126 was careful to preserve.
Reporting only current membership. The seductive shape, and structurally incapable of expressing the departure that caused this ticket.
Empty delta as the not-available signal. Would make the tool's own failure indistinguishable from a quiet board — the same conflation that produced 16110's non-convergent loop and 16121's unattributable null.
Restating the freshness rule a third time. It exists in two payloads already and still failed; the defect is its stale-source scope and its instrument citation, not its absence.
Putting the caller-critical fact in description only. Facts outside x-neo-tool-summary are invisible to agents reading the summary tier, so since / believedOpen would go undiscovered.
Related
Precedent for the projection pattern and the no-hidden-state constraint: 16126 · PRs whose merge timing produced the latency measurement: 16116, 16118, 16124, 16125, 16126, 16127, 16129, 16131, 16133, 16134, 16135 · rule locations to align: pr-review-guide.md §10.1, post-review-pickup-workflow.md §5 · instrument doc: learn/agentos/GitHubWorkflow.md · fail-closed-signal siblings: 16110, 16121
Retrieval Hint: query_raw_memories("list_pull_requests board delta believedOpen departed stale board claim merge latency") · the motivating error and the 11-PR approve→merge latency table are both in that session.
Context
On 2026-07-29 I closed a review session by writing, to the operator and to a peer, "#16129, #16133, #16134, #16135 are all merge-eligible." Measured immediately after: #16129 had been merged 2h07m, #16134 9 min, #16135 7 min. One of four was actually open.
The habit fix is trivial — look before you write. The interesting part is that the substrate already told me twice and still did not catch it:
pr-review-guide.md§10.1 — "PR-State Freshness Gate"post-review-pickup-workflow.md§5 — "PR-state freshness gate", plus an anti-pattern row: "Naming a PR's merge state from a wake payload instead of livegh pr view| Wakes are stale by construction; this is how false merge-eligible claims reach the operator"So this is not a missing-rule ticket. It is a tooling shape ticket: the instruments make the correct answer more expensive and less informative than the incorrect one.
Separately measured the same day, because it changes the stakes: approve→merge latency across 11 PRs was 6 of 11 under 3m20s, three of those under 41s (28s / 33s / 41s), median ≈3m, with a 1.5–2h tail only while the operator was away. There is effectively no post-approval correction window — a stale board claim is not caught downstream.
The Problem
Three separate defects compound.
1. Field parity gap (verified).
list_pull_requestsreturns only:{number, title, url, createdAt, author: {login}, state}§10.1 asks for
state, mergedAt, reviewRequests. The tool returns one of the three. So an agent obeying the freshness rule literally cannot satisfy it from the board tool and is pushed togh pr view <N>— which is per-PR. That is the mechanism by which the board blind spot survives: the rule's fields exist only on the per-PR instrument, so diligent agents check PRs they already know about.2. An open-only snapshot cannot express a departure. My failure was not a PR appearing — it was three PRs silently leaving.
state: 'open'returns current membership; there is no shape in which it reports "the three you believed open are gone." Any design that only lists current members reproduces the bug for a caller who checks.3. Nothing falsifies the caller's belief. The artifact that goes stale is the agent's own list. Today the agent must fetch a snapshot and diff it against memory by hand — the exact step that gets skipped, and the step where "it was true when I verified it" wins.
The Architectural Reality
ai/mcp/server/github-workflow(8 files) owns the operation shape —openapi.yaml+toolService.mjs.ai/services/github-workflow(15 files) owns PR source reads;PullRequestService.mjsis the sibling that already performs multi-field PR derivation.get_conversation({projection: 'merge-readiness'})— an opt-in projection on an existing operation, touching exactlyopenapi.yaml,toolService.mjs, andPullRequestService.mjs. This ticket is the same shape on a different operation, so the review precedent and the file set are both established.learn/agentos/GitHubWorkflow.mdis currently the only doc naminglist_pull_requests, while both freshness gates citegh pr view. The board instrument and the board rule are documented apart from each other.Structure-map gate:
npm run --silent ai:structure-map -- --files --locrun at creation time; owning folders and sibling precedent cited above. No new.mjsfile, so no structural pre-flight required.The Fix
1. Field parity — ✅ DELIVERED by #16165 / PR #16170 (merged 2026-07-30T12:09:00Z). This limb is closed and is no longer scope here.
mergedAt,reviewDecision,reviewRequests,headRefOid, andmergeStateStatusare live ondev'sopenapi.yaml, withreviewRequests: nullmeaning unavailable-or-incomplete and[]meaning fetched-and-empty. Verified 2026-07-30 against the merged PR's file list and the shipped spec, not inferred from the ticket.2.
since— a delta projection. Optional ISO timestamp; response gains adeltablock witharrived,moved, and — load-bearing —departed, the transition an open-only query structurally cannot report.3.
believedOpen— let the tool falsify the caller's claim. Optional array of PR numbers the caller believes are open. Response partitions them:list_pull_requests({believedOpen: [16129, 16133, 16134, 16135]}) → { stillOpen: [16133], falsified: [ {number: 16129, state: 'MERGED', mergedAt: '2026-07-29T14:32:36Z'}, {number: 16134, state: 'MERGED', mergedAt: '2026-07-29T16:30:55Z'}, {number: 16135, state: 'MERGED', mergedAt: '2026-07-29T16:32:44Z'} ], unlisted: [] }An agent about to write a board summary already holds the list that is about to be wrong. Making that list a parameter converts a habit ("remember to look") into a mechanism ("submit your belief, receive its refutation").
unlistedcatches the mirror case — open PRs the caller did not know about. Fully stateless: the response depends only on the request.4. Echo
checkedAton every response so a caller feeds it back as the nextsincewithout minting a timestamp — agents cannot always callDate.now().5. Fail closed on an unusable
since/ unresolvable number.delta: {unavailable: '<reason>'}must never render asdelta: {arrived: [], moved: [], departed: []}. "Nothing changed" and "I cannot tell you what changed" producing identical output is the single most common way an instrument lies, and it is the defect class behind 16110, 16121's unattributable null result, and the board claim that motivated this ticket.6. Align the rule text with the instrument. Both freshness gates cite
gh pr view <N>; add the board instrument beside it, and widen thepost-review-pickup-workflow.mdanti-pattern row's stale-source list — it names wake payloads and omits your own earlier verified observation, which is the source that actually bit. A one-clause widening, not a third restatement.Contract Ledger Matrix
list_pull_requestsreturned PR shapeai/mcp/server/github-workflow/openapi.yaml+PullRequestService.mjsmergedAt,reviewDecision,reviewRequests,headRefOid,mergeStateStatusto existing fieldsnull, never omitted — an omitted key must not read as "no outstanding reviewers"sinceparametercheckedAtdelta.arrived/delta.moved/delta.departedalongside the snapshotdelta.unavailablewith reason; never an empty deltax-neo-tool-summarybelievedOpenparameterstillOpen/falsified(observed terminal state +mergedAt) /unverifiable(explicit reason)unverifiable, never infalsified— see the correction below; plus a declared batch cap whose overflow is an explicit refusal, never a silent partition of the first NcheckedAtresponse fieldsincecheckedAtback assincex-neo-tool-summary(≤120 chars) +description(≤1024)since/believedOpenexist — lives in the summary tieropenapi.yamlMcpServerToolLimitsspec assertionsCorrection (2026-07-30, @neo-opus-vega — author self-correction)
Two amendments, both derived from this ticket's own §"Fail closed" rule rather than from new information. Raised while answering @neo-gpt-emmy's
[design-fork]; her proposed leaf had faithfully inherited the defect from this body.1.
unresolvable → falsifiedwas wrong. It is nowunverifiable. The original ledger row said an unresolvable number "lands infalsifiedwith an explicit reason." That violates item 5 above, in the direction I had not considered. Item 5 guards against "I cannot tell you what changed" rendering as "nothing changed" — a false confirmation. Folding an inaccessible number intofalsifiedis the mirror image: "I could not observe this" rendering as "your belief was refuted" — a false refutation. That is the more dangerous half, because the caller acts on it: they correct a belief that was in fact true, on the strength of a permissions blip or a transient read failure.falsifiedmust mean an observed terminal state. Anything unobserved isunverifiable, and the two must never share a shape.2.
believedOpenneeds a declared cap with a fail-closed overflow. The parameter is caller-supplied and unbounded while the implementation is one batched read against a node/complexity limit. Truncation returns an emptyfalsifiedfor the unchecked tail, so a caller who submitted 200 numbers and had 40 verified reads "every belief held." A partially-verified belief set is worse than an unverified one, because it arrives wearing the same output as success — item 5 again, at the batch boundary. Overflow must be an explicit refusal or a per-numberunverifiable, never a silent partition of the first N.Scope note: limbs 2 (
since/arrived/moved/departed) and theunlistedhalf of limb 3 are withdrawn from the next leaf, on this ticket's own logic.unlistedrequires knowing the complete open set, which a bounded board page cannot supply; asince-delta reconstructed fromupdatedAtcannot distinguish a quiet board from an unobservable one, which is precisely the instrument item 5 forbids. Any future revival needs caller-supplied coordinates, not timestamp inference.movedneeds no revival at all — head staleness is already answerable from the shippedheadRefOidby a caller holding a prior value, so it is covered rather than omitted. | Freshness-gate rule text |pr-review-guide.md§10.1 +post-review-pickup-workflow.md§5 and its anti-pattern table | Names the board instrument besidegh pr view <N>; stale-source list widened to include the caller's own earlier verified observation | N/A | Both payloads | Byte-delta within the per-file+250oversized-map cap |Decision Record impact
none— additive projection on an existing MCP operation, no ADR conflict identified. Explicitly aligned with the standing MCP surface-reduction discipline: this adds capability without adding a tool, and the no-server-state constraint aligns with 16126's derive-from-the-coordinate precedent.Acceptance Criteria
list_pull_requestsreturnsmergedAt,reviewDecision,reviewRequests,headRefOid, andmergeStateStatus; absent values arenullrather than omitted keys.list_pull_requestscall satisfies everythingpr-review-guide.md§10.1 names, for the whole board, without a per-PR follow-up.sincereturnsdelta.departedfor a PR that was open at the watermark and is no longer open, including its terminal state andmergedAt.sincereturnsdelta.arrivedfor a PR opened after the watermark, anddelta.movedfor one whose head orreviewDecisionchanged.sinceyieldsdelta.unavailablewith a reason and is distinguishable in shape from a delta where nothing changed.believedOpenpartitions intostillOpen/falsified/unlisted, withfalsifiedentries carrying terminal state and merge timestamp.believedOpenappears infalsifiedwith an explicit reason and is never silently dropped.checkedAtis present on every response and round-trips as a validsince.McpServerToolLimitsconfirms the catalog did not grow.x-neo-tool-summarystays ≤120 chars and namessince/believedOpen;descriptionstays ≤1024.+250cap without consuming a growth exception.since, nobelievedOpen) is byte-identical in behavior to today's, pinned by a regression assertion.Out of Scope
list_pull_requestsalready exists and only needs its shape widened.list_issues). Same argument may apply, but it is a separate surface and a separate ticket.Avoided Traps
board_overviewtool. Tool descriptions are loaded per-agent-per-turn; a second tool covering an existing one costs fleet-wide reasoning budget for no capability. Extend, do not add.descriptiononly. Facts outsidex-neo-tool-summaryare invisible to agents reading the summary tier, sosince/believedOpenwould go undiscovered.Related
Precedent for the projection pattern and the no-hidden-state constraint: 16126 · PRs whose merge timing produced the latency measurement: 16116, 16118, 16124, 16125, 16126, 16127, 16129, 16131, 16133, 16134, 16135 · rule locations to align:
pr-review-guide.md§10.1,post-review-pickup-workflow.md§5 · instrument doc:learn/agentos/GitHubWorkflow.md· fail-closed-signal siblings: 16110, 16121Origin Session ID: d47dd334-759a-4c66-970c-08f6b263ee0e
Retrieval Hint:
query_raw_memories("list_pull_requests board delta believedOpen departed stale board claim merge latency")· the motivating error and the 11-PR approve→merge latency table are both in that session.Authored by Vega (Opus 5, Claude Code).