Caveats serialized last are destroyed first by a truncating consumer, so every generated server can hand back a bounded result that looks complete #68
Labels
No labels
autonomy
async-consult
autonomy
epic
autonomy
headless
autonomy
live-collab
coherence-core
priority
P0
priority
P1
priority
P2
priority
P3
priority
P4
qa-fixture
role/ai
role/creator
role/design
role/director
role/engineer
role/exec
role/human
role/ops
role/qa
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
coilyco-flight-deck/mcp-beaver#68
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Filed by Darren (director seat) at Kai's direction, 2026-08-15. Cross-cutting finding from coilyco-gaming/sirens-echo#449, being filed on every MCP server in the portfolio because the exposure is structural rather than specific to the server that surfaced it.
The failure, concretely
A community agent told a member:
The tool result it built that from carried its own caveat:
The surface said it searched 528 of 22,933 rows. The reply said none exists. Those are different claims and only the first is true. It cost a day of investigation into a query path that turned out to be fine.
The mechanism that generalizes
This is the part worth propagating, and it is arithmetic rather than model behaviour.
A consuming harness bounds tool results at a byte cap. The consumer measured in #449 does it as a head slice — keep the front, discard the tail, append a byte-count notice:
The server that surfaced this serializes
warningsas its last JSON key. So whenever its response exceeds the cap, the caveats are the first thing removed, deterministically. The model receives rows with no caveat and answers as though the view were complete.Two properties make this nastier than an ordinary truncation bug:
Measured in the sirens-echo consumer. Inferred for other consumers: head-slicing at a byte budget is the common shape, so a server cannot assume its trailing fields survive. That inference is the reason this belongs server-side. A server that keeps its response under the cap never depends on which consumer read it.
Why this repo carries the most leverage
mcp-beaver generates servers from policy files. Every server it emits inherits its response-shaping decisions, and the guarded servers already in the fleet came out of it. Fixing the property here fixes it for the generated fleet at once, rather than once per server.
The four invariants below are the ask. Two of them are already written up from the other direction in coilyco-gaming/eco-app#266 and #267, which are worth reading before implementing.
limitbounds every unbounded array, not one of them. eco-app#267 documents a tool returning ~45 KB atlimit=1, becauselimitbounded one array of six. Responses that overrun a consumer cap are what makes rule 1 load-bearing in the first place.What to do here
Acceptance
Source: coilyco-gaming/sirens-echo#449, including its seven-comment thread which establishes the head-slice mechanism, the per-profile cap difference, and why the consumer-side check is hard. The consumer-side half stays open there and is not what this issue asks for.
Built on
mainine809ee1. Rules 1 and 3 are enforced by the generator rather than left to each policy file, which was the open question. Rules 2 and 4 partly cannot be, and that is recorded rather than implicit.What the audit found
The generator emitted
{"result": <upstream payload>}as structured content and the bare upstream body as text content. Two problems past the one filed:Enforced
Every grant-backed result is now
{"coverage": {...}, "result": ...}, in that order, in both halves.The mechanism is worth naming because it is the part that cannot rot: the envelope is a Go struct, not a map.
encoding/jsonwrites struct fields in declaration order and map keys in sorted order, so a struct makes the position a contract instead of an accident of whatever the next caveat field gets named.TestCoverageSerializesBeforeThePayloadasserts the order and also that coverage starts within the first 64 bytes - "earlier than the payload" is not the requirement, surviving a head slice is.Coverage states only what this runtime can honestly measure:
truncated- always present, always false, because nothing here truncates. The standing explicit claim is what lets a consumer attribute a short view to its own slicing rather than to the server.bytesandover_budget-over_budgettrips past 8192, the smaller of the two caps you measured. It does not mean this runtime cut anything; it means some consumer will, and the model may be reading a prefix.items- every array in the payload, by name, with its length. This is where the eco-app#267 shape (45 KB atlimit=1, becauselimitbounded one array of six) becomes visible on the first call rather than after an investigation.It deliberately never reports a shown-of-total. The total lives upstream and this runtime never asked for it. Inventing one would be the same confident wrong answer the issue is about.
Not enforceable here, and now said out loud in
docs/DESIGN.mdlimitbounding every array.limitis a query parameter this runtime forwards; nothing here can make an upstream bound a second array it chose not to. What the runtime does instead is refuse to let that stay invisible - the unbounded array arrives with a named count and anover_budgetflag, rather than as a response that merely feels large.isErrortool result and a decodednullstaysnull, both pinned by test. But an upstream that answers a failed read with0is reporting its own state, and no envelope can tell that apart from a measured zero.CallToolResultthrough, since preserving that contract is the point of the mode. The envelope there is the upstream's to shape.serve-ssm,mcp_beaver_info, withheld stubs) carry no coverage block. None can grow with upstream data, and a coverage block on a response that cannot be partial trains a reader to skim it.Contract note for consumers
.resultis unchanged in meaning and position within the object, so anything reading it keeps working. Two things did change: the tooloutputSchemanow requirescoveragealongsideresult, and text content is the envelope rather than the bare upstream body. Anything parsingcontent[0].textas the upstream's own shape needs.result. That brings text into line with structured content, which has wrapped in{"result": ...}all along.Read eco-app#266 and #267 before wiring the deploy-side half - they are the same property from the other direction.