A mapped body cannot carry a pinned constant, which blocks any guardfile needing both a renamed input and a fixed upstream parameter #311

Closed
opened 2026-08-19 16:07:19 +00:00 by coilyco-ops · 4 comments
Member

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:

  1. A renamed input. Exa's required parameter is query, and query is a reserved engine flag (opcore/flagcheck.go), so no input may be named it. map is the only construct that renames, hence map "search_text" to="query".
  2. A pinned constant. Exa's contents parameter 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:

func validateBodyMappingMode(d Descriptor) error {
	if len(d.BodyMappings) == 0 { return nil }
	if len(d.BodyFlags) > 0 {
		return fmt.Errorf("body fields and body mappings cannot be combined (fail-closed)")
	}
	if len(d.FixedBody) > 0 {
		return fmt.Errorf("fixed body and body mappings cannot be combined (fail-closed)")
	}
	return validateBodyMappings(d.BodyMappings)
}

And the mapping path cannot carry it either, because every mapped value is a required string. Same file:

f := Field{Name: name, Type: "string", Required: true}
func projectMappedBody(body map[string]any, mappings []BodyMapping) ([]byte, error) {
	out := make(map[string]any, len(mappings))
	for _, mapping := range mappings {
		value, err := mappedString(body, mapping.SourcePath)
		...
		out[mapping.Target] = value
	}

descriptor.go states the same intent in a comment: "required string input paths projected onto fresh top-level body keys."

Exa's contents takes an object ({"text": true}, or a highlights / summary object). A string at that key cannot express it. So routing it through map does 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:

That choice costs the set node: fixed body and body mappings cannot be combined (opcore/body_mapping.go). Every pin below is therefore an absence rather than a fixed value, which turns out to be the stronger form anyway.

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:

  • Allow FixedBody to combine with BodyMappings. The two do not actually conflict: projectMappedBody builds a fresh map, so fixed keys could be seeded into out before the mappings are projected, with a collision check rejecting a fixed key that a mapping also targets. The existing BodyFlags prohibition can stay as is; that one is a real ambiguity and this one is not.
  • Add a pin construct valid inside a mapped body, for example 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:

Adding contents later is a one-line change and MUST NOT be made without revisiting #177 [...]

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.

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: 1. **A renamed input.** Exa's required parameter is `query`, and `query` is a reserved engine flag (`opcore/flagcheck.go`), so no input may be named it. `map` is the only construct that renames, hence `map "search_text" to="query"`. 2. **A pinned constant.** Exa's `contents` parameter 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`: ```go func validateBodyMappingMode(d Descriptor) error { if len(d.BodyMappings) == 0 { return nil } if len(d.BodyFlags) > 0 { return fmt.Errorf("body fields and body mappings cannot be combined (fail-closed)") } if len(d.FixedBody) > 0 { return fmt.Errorf("fixed body and body mappings cannot be combined (fail-closed)") } return validateBodyMappings(d.BodyMappings) } ``` **And the mapping path cannot carry it either, because every mapped value is a required string.** Same file: ```go f := Field{Name: name, Type: "string", Required: true} ``` ```go func projectMappedBody(body map[string]any, mappings []BodyMapping) ([]byte, error) { out := make(map[string]any, len(mappings)) for _, mapping := range mappings { value, err := mappedString(body, mapping.SourcePath) ... out[mapping.Target] = value } ``` `descriptor.go` states the same intent in a comment: "required string input paths projected onto fresh top-level body keys." Exa's `contents` takes an object (`{"text": true}`, or a `highlights` / `summary` object). A string at that key cannot express it. So routing it through `map` does 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: > That choice costs the `set` node: fixed body and body mappings cannot be combined (`opcore/body_mapping.go`). Every pin below is therefore an absence rather than a fixed value, which turns out to be the stronger form anyway. 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: * **Allow `FixedBody` to combine with `BodyMappings`.** The two do not actually conflict: `projectMappedBody` builds a fresh map, so fixed keys could be seeded into `out` before the mappings are projected, with a collision check rejecting a fixed key that a mapping also targets. The existing `BodyFlags` prohibition can stay as is; that one is a real ambiguity and this one is not. * **Add a pin construct valid inside a mapped body**, for example `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: > Adding `contents` later is a one-line change and MUST NOT be made without revisiting #177 [...] **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.
Author
Member

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.go exists for a mirror-image problem, and its comment describes it in terms that transfer word for word:

The gap it closes is that set writes fixed BODY values only, so a GET endpoint whose scope rides in the query string had nowhere to put it. The only alternative was declaring the scope as a caller query field, which hands the model the very parameter the deployment is trying to fix - for Steam, that is the difference between "Kai's library" and "anyone's library".

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):

args := splitArgs(schema, rawArgs)
// Applied after splitArgs, which drops anything the schema does not
// name. A pinned parameter is absent from the schema, so the caller
// cannot supply it and this assignment cannot be contested.
if len(pins) > 0 {
    resolved, err := resolveQueryPins(ctx, pins)
    ...
    for name, value := range resolved {
        args.Query[name] = value
    }
}
resp, err := (&opcore.Operation{Desc: desc, RT: rt}).Execute(ctx, args)

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:

  1. projectMappedBody discards it. For a descriptor carrying mappings the body is rebuilt from the mappings alone, so any key beaver seeded into args.Body that 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.
  2. Pinning a mapped source instead would still fail. A mapped source is a schema field (Type: "string", Required: true), so unlike a query pin it is not absent from the tool schema, and mappedString would 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 FixedBody to 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 carry set contents={text:true} beside its existing map. 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 ArgPin and queryPin, for a case where the engine already has the right word (set) and merely refuses to let it coexist with map.

## 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.go` exists for a mirror-image problem, and its comment describes it in terms that transfer word for word: > The gap it closes is that `set` writes fixed BODY values only, so a GET endpoint whose scope rides in the query string had nowhere to put it. The only alternative was declaring the scope as a caller query field, **which hands the model the very parameter the deployment is trying to fix** - for Steam, that is the difference between "Kai's library" and "anyone's library". 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`): ```go args := splitArgs(schema, rawArgs) // Applied after splitArgs, which drops anything the schema does not // name. A pinned parameter is absent from the schema, so the caller // cannot supply it and this assignment cannot be contested. if len(pins) > 0 { resolved, err := resolveQueryPins(ctx, pins) ... for name, value := range resolved { args.Query[name] = value } } resp, err := (&opcore.Operation{Desc: desc, RT: rt}).Execute(ctx, args) ``` 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: 1. **`projectMappedBody` discards it.** For a descriptor carrying mappings the body is rebuilt from the mappings alone, so any key beaver seeded into `args.Body` that 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. 2. **Pinning a mapped source instead would still fail.** A mapped source is a schema field (`Type: "string", Required: true`), so unlike a query pin it is not absent from the tool schema, and `mappedString` would 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 `FixedBody` to 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 carry `set contents={text:true}` beside its existing `map`. 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 `ArgPin` and `queryPin`, for a case where the engine already has the right word (`set`) and merely refuses to let it coexist with `map`.
Author
Member

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:

Sent Result
parameter absent 200
contents={"text": true} (object) 200, page text returned
contents="text" (string) HTTP 400
contents="true" (string) HTTP 400
Validation error: Invalid input: expected object, received string at "contents"

Since insertMappingField emits every mapped leaf as Type: "string" and projectMappedBody assigns 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 through map in 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 map silently 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 FixedBody into 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.go or to ship a guardfile that 400s.

## 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: | Sent | Result | | --- | --- | | parameter absent | 200 | | `contents={"text": true}` (object) | 200, page text returned | | `contents="text"` (string) | **HTTP 400** | | `contents="true"` (string) | **HTTP 400** | ``` Validation error: Invalid input: expected object, received string at "contents" ``` Since `insertMappingField` emits every mapped leaf as `Type: "string"` and `projectMappedBody` assigns 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 through `map` in 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 `map` silently 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 `FixedBody` into 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.go` or to ship a guardfile that 400s.
Author
Member

Scope 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 FixedBody into 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 through mappedString, 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.

**Scope 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 `FixedBody` into 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 through `mappedString`, 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.
Author
Member

Implemented in #313, with one correction: the proposed fix was necessary but not sufficient.

The analysis here holds up in full. map is the only construct that renames, set refused to combine with it, and the follow-up comment is right that beaver cannot absorb this the way querypin.go absorbed the query-side case, because projectMappedBody rebuilds the body from the mappings alone and drops anything seeded into args.Body.

Where the plan fell short

This issue expected the guardfile to then carry set contents={text:true} beside its map, and called that a one-line change. That syntax does not exist. A KDL property holds a scalar, and contents takes an object. Against the parser:

set contents={text:#true}
  parse error at 3:67: expected value, got {

So allowing FixedBody to combine with BodyMappings unblocks 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:

  1. set seeds the map the mappings project onto. A key that is both pinned and mapped fails closed rather than picking a silent winner. The BodyFlags prohibition stays, that one being a real ambiguity between two model-supplied values.
  2. set gains 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:

can search result {
    path "/search"
    body { map "search_text" to="query" }
    set numResults=5 { contents { text #true }; categories "news" "papers" }
}

A caller naming contents or numResults does 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_body asserted exactly the rule this changes, and kept passing after I removed the rule. It was failing on a KDL lex error, a bare true needing #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 with set + map legal 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#741 stands as written. Once #313 lands, the retracted "one-line change" becomes true again in the set-beside-map form.

**Implemented in #313, with one correction: the proposed fix was necessary but not sufficient.** The analysis here holds up in full. `map` is the only construct that renames, `set` refused to combine with it, and the follow-up comment is right that beaver cannot absorb this the way `querypin.go` absorbed the query-side case, because `projectMappedBody` rebuilds the body from the mappings alone and drops anything seeded into `args.Body`. ## Where the plan fell short This issue expected the guardfile to then carry `set contents={text:true}` beside its `map`, and called that a one-line change. **That syntax does not exist.** A KDL property holds a scalar, and `contents` takes an object. Against the parser: ``` set contents={text:#true} parse error at 3:67: expected value, got { ``` So allowing `FixedBody` to combine with `BodyMappings` unblocks 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: 1. **`set` seeds the map the mappings project onto.** A key that is both pinned and mapped fails closed rather than picking a silent winner. The `BodyFlags` prohibition stays, that one being a real ambiguity between two model-supplied values. 2. **`set` gains 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: ```kdl can search result { path "/search" body { map "search_text" to="query" } set numResults=5 { contents { text #true }; categories "news" "papers" } } ``` A caller naming `contents` or `numResults` does 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_body` asserted exactly the rule this changes, and kept passing after I removed the rule. It was failing on a KDL lex error, a bare `true` needing `#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 with `set` + `map` legal 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#741` stands as written. Once #313 lands, the retracted "one-line change" becomes true again in the `set`-beside-`map` form.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/umbra#311
No description provided.