A mapped body cannot carry a pinned constant, which blocks any guardfile needing both a renamed input and a fixed upstream parameter #311
Labels
No labels
burndown-2026-06
sunday-sprint
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/umbra#311
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?
Found while trying to make a concrete change to a consumer guardfile in
coilyco-bridge/deploy,services/sirens-echo/exa-mcp.mcp.kdl. The change is blocked here rather than there, so filing it in the owning layer.What is blocked
That guardfile wraps Exa's search endpoint. It needs two things at once:
query, andqueryis a reserved engine flag (opcore/flagcheck.go), so no input may be named it.mapis the only construct that renames, hencemap "search_text" to="query".contentsparameter controls whether page text comes back. The operator wants it fixed on, at a shape the operator chooses, and specifically not something the model can name or vary, because the same parameter family controls cost.Those two cannot be expressed together today. The result is that the parameter simply cannot be enabled, in either safe or unsafe form.
Why, precisely
A fixed body is rejected outright when mappings are present.
http/opcore/body_mapping.go:And the mapping path cannot carry it either, because every mapped value is a required string. Same file:
descriptor.gostates the same intent in a comment: "required string input paths projected onto fresh top-level body keys."Exa's
contentstakes an object ({"text": true}, or ahighlights/summaryobject). A string at that key cannot express it. So routing it throughmapdoes not merely weaken the control, it does not function at all, and it would also force the model to supply a required argument on every call.Why this is worth fixing rather than working around
The consumer guardfile's design is built on absence. Its own comment says it well:
Absence is a genuinely strong control while every desired value is a default. It stops working the moment an operator needs a non-default value pinned. Then the only expressible option is to hand the key to the model, which is strictly the worst of the three outcomes: the operator wanted a constant, and the engine's shape offers either nothing or a model-controlled variable.
That is the general failure, and it is not specific to Exa. Any upstream API with a reserved-word-colliding required parameter plus an operator-chosen constant hits it.
What would unblock it
Either would do, and the first is smaller:
FixedBodyto combine withBodyMappings. The two do not actually conflict:projectMappedBodybuilds a fresh map, so fixed keys could be seeded intooutbefore the mappings are projected, with a collision check rejecting a fixed key that a mapping also targets. The existingBodyFlagsprohibition can stay as is; that one is a real ambiguity and this one is not.pin "contents" value={text:true}, so a mapped body can carry constants without opening a model-facing input.Either restores the property the consumer actually wants, which is a value the model cannot name, cannot vary, and is not required to supply.
One documentation consequence downstream
The consumer guardfile currently tells its reader:
That sentence is wrong, and an operator has already acted on it. It is not a one-line change, it is not currently any number of lines in that repo, and the gating condition it names is not the thing standing in the way. I am correcting it on
coilyco-bridge/deploy#741, but the underlying reason lives here.Not urgent
No production behaviour is wrong today. The capability is absent rather than broken, and the consumer is running safely in its restricted form. This is a blocked change, not an incident.
Checked whether mcp-beaver could absorb this instead. It cannot, and the reason is precedent-shaped.
Filing this here rather than in beaver was worth challenging, because beaver has already solved this exact class of gap once, at its own layer, without an umbra change.
The precedent
internal/mcpserver/querypin.goexists for a mirror-image problem, and its comment describes it in terms that transfer word for word:That is the Exa case with body and query swapped. Same requirement, same unacceptable fallback, same "pinned name is still absent from the tool schema" property wanted.
And beaver implemented it with no engine change, by injecting after argument splitting and before execution (
internal/mcpserver/server.go):So the obvious question is why beaver cannot add the body counterpart and set
args.Body[...]in the same place.Why the same trick does not work for a mapped body
Two independent blocks, either of which is sufficient:
projectMappedBodydiscards it. For a descriptor carrying mappings the body is rebuilt from the mappings alone, so any key beaver seeded intoargs.Bodythat is not a mapped source is dropped before the request is marshalled. The query path has no equivalent rebuild, which is exactly why the trick worked there.Type: "string", Required: true), so unlike a query pin it is not absent from the tool schema, andmappedStringwould still deliver a string where Exa needs an object.So the higher-layer workaround that rescued the query case is unavailable here. This one has to be the engine.
Which makes the smaller of the two proposals clearly right
Allowing
FixedBodyto seed the projected map before mappings project onto it does two things at once: it unblocks the consumer, and it makes the consumer's long-standing claim that enabling this is "a one-line change" actually true, since the guardfile would then just carryset contents={text:true}beside its existingmap. No beaver change, no new construct, no new concept for guardfile authors to learn.The alternative pin construct would work too, but it would be beaver's third pin mechanism after
ArgPinandqueryPin, for a case where the engine already has the right word (set) and merely refuses to let it coexist withmap.The "just let the model supply it" escape is closed too, measured against the live API.
The obvious way to avoid this issue entirely is to give up on pinning: accept a weaker posture, expose the parameter as an ordinary mapped input, and let the caller supply it. That would need no engine change at all. It does not work, and the reason is worth recording here because it applies to every mapped body, not just this consumer.
Four calls to the upstream API, varying only the parameter in question:
contents={"text": true}(object)contents="text"(string)contents="true"(string)Since
insertMappingFieldemits every mapped leaf asType: "string"andprojectMappedBodyassigns that string directly onto the target key, a mapped body can only ever place a string at a wire key. Any upstream that requires a non-string at a parameter is therefore unreachable throughmapin any configuration, pinned or not, operator-supplied or caller-supplied.Which generalises the issue
I filed this as "a mapped body cannot carry a pinned constant." That is the case that motivated it, but it is the narrower statement. The accurate one is:
A mapped body cannot carry a non-string value at all, from any source.
The pinning problem is one consequence. The other is that
mapsilently restricts a guardfile to upstreams whose every mapped parameter is a string, and nothing surfaces that restriction at authoring time. The failure arrives as an upstream 400 at call time, which is a poor place to discover a structural limit.That may widen what the fix should cover. Seeding
FixedBodyinto the projected map, the smaller proposal above, solves the pinning half cleanly and leaves the typing half untouched: constants could then be any JSON shape, while caller-supplied mapped values stay strings. For the consumer here that is sufficient, since the value wanted is a constant. Whether caller-supplied mapped values should also be able to carry a type is a larger question and probably a separate issue.At minimum, the string-only projection deserves to be stated in the guardfile-authoring docs, because right now the only way to learn it is to read
body_mapping.goor to ship a guardfile that 400s.mapsilently restricts a guardfile to string-valued upstream parameters and the only signal is a 400 at call time #312Scope note for whoever is implementing this: the widening in my comment above is now #312, and it does not change this issue.
Split out because this one is in flight and a broadened scope arriving as a mid-flight comment is easy to miss or, worse, easy to half-absorb.
Build this issue as originally written. Seeding
FixedBodyinto the projected map is correct, is sufficient for the consumer that motivated it, and needs nothing from #312. A seeded fixed value is written as its own JSON shape and never passes throughmappedString, so it lands non-string constants without touching how caller-supplied mapped values are typed.#312 covers the separate fact that caller-supplied mapped values are string-only with no authoring-time signal. Landing this issue alone leaves that entirely in place, which is a reasonable stopping point and is recorded there so it stays a decision rather than an oversight.
Implemented in #313, with one correction: the proposed fix was necessary but not sufficient.
The analysis here holds up in full.
mapis the only construct that renames,setrefused to combine with it, and the follow-up comment is right that beaver cannot absorb this the wayquerypin.goabsorbed the query-side case, becauseprojectMappedBodyrebuilds the body from the mappings alone and drops anything seeded intoargs.Body.Where the plan fell short
This issue expected the guardfile to then carry
set contents={text:true}beside itsmap, and called that a one-line change. That syntax does not exist. A KDL property holds a scalar, andcontentstakes an object. Against the parser:So allowing
FixedBodyto combine withBodyMappingsunblocks the combination while leaving the value still inexpressible. The Exa parameter would have stayed blocked on a second wall one step further in.What landed
Both halves:
setseeds the map the mappings project onto. A key that is both pinned and mapped fails closed rather than picking a silent winner. TheBodyFlagsprohibition stays, that one being a real ambiguity between two model-supplied values.setgains a block form so a pin can carry a shape: one node per key, one argument, several for an array, or a nested block for an object, nesting as deep as the upstream needs.This is option 2 in substance, but spelled with the word the engine already has rather than a third pin mechanism, which is what the follow-up comment argued for. The Exa shape is now asserted from KDL onto the wire:
A caller naming
contentsornumResultsdoes not reach them, and neither key appears in the input schema.Two things found on the way
A test was passing on nothing.
TestParseInlineBodyMappingsRejectOtherBodyModes/fixed_bodyasserted exactly the rule this changes, and kept passing after I removed the rule. It was failing on a KDL lex error, a baretrueneeding#true, so it had never tested the body-mode rejection. Replaced, and the new fail-closed table asserts the error is not a parse error so this cannot recur silently.Two other body assemblers would have gone wrong. The action dry-run planner and the CLI both short-circuited on
FixedBody, so withset+maplegal they would have sent the pins without the mapped keys. The dry-run now previews through the engine. The CLI refuses a mapped-body grant outright, mapped sources mounting no CLI flag, so that surface can never fill one; it previously sent an empty body for such a grant.Downstream
The correction on
coilyco-bridge/deploy#741stands as written. Once #313 lands, the retracted "one-line change" becomes true again in theset-beside-mapform.