A mapped body can only ever send strings, so map silently restricts a guardfile to string-valued upstream parameters and the only signal is a 400 at call time #312

Open
opened 2026-08-19 16:20:51 +00:00 by coilyco-ops · 0 comments
Member

Self-contained. Does not block #311 and does not change what #311 should do — see the boundary section at the end. Split out at Kai's direction because #311 is in flight and this is a wider finding that should not ride on it as a comment.

The limit

A body block written as mappings projects strings and only strings onto the wire, regardless of what the caller supplies or what the upstream expects.

http/opcore/body_mapping.go, where the model-facing schema is built:

f := Field{Name: name, Type: "string", Required: true}

and where the outgoing body is assembled:

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)
		if err != nil {
			return nil, err
		}
		out[mapping.Target] = value
	}
	return json.Marshal(out)
}

mappedString returns a string, and that string is what lands at mapping.Target. descriptor.go states the same intent plainly: "required string input paths projected onto fresh top-level body keys."

A dotted source path does not change this. insertMappingField builds a nested input shape (Type: "object" for interior segments), but the value projected onto the wire key is still the leaf string.

So any upstream requiring a non-string at a mapped parameter is unreachable through map, in every configuration.

Why this is worth its own issue rather than a footnote

Nothing surfaces the restriction at authoring time. A guardfile that maps a parameter the upstream wants as an object, number, or boolean parses cleanly, builds cleanly, ships, registers its tool, and then fails on every call with an upstream error.

Measured against a live upstream (Exa's /search), varying only the one parameter:

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

That 400 is the first and only notice the author gets, and it arrives in production against a metered third-party API. The engine knew at parse time that this mapping could only ever emit a string; the upstream contract is the part it cannot know, but the emitted type is not.

Why it is easy to hit

map is not an optional stylistic choice. It is the only construct that renames an input to a different upstream key, because a body field carrying an upstream alias is rejected outright:

input %q sets an upstream name outside query parameters (fail-closed)

So any guardfile whose upstream requires a parameter name that collides with ReservedFlagNames (dry-run, query, output, body-file) is forced into mapped-body mode for that leaf, and mapped-body mode then quietly forbids every non-string parameter on that same leaf, including ones entirely unrelated to the collision that forced the mode.

The two constraints compose into something neither one states. An author reading either rule in isolation would not predict it.

What would help, smallest first

  1. Say it in the guardfile-authoring docs. Today the only ways to learn this are reading body_mapping.go or shipping a 400. docs/specverb-request.md already covers the reserved-name rules and is the natural home.
  2. Fail at parse time where the intent is visibly non-string. If a map target is given a value shape that cannot be a string, reject it with a message naming the limit rather than deferring to the upstream. This is partial, since the engine cannot know an arbitrary upstream's contract, but it converts the most common authoring mistake from a runtime 400 into a build error, which matches the fail-closed posture elsewhere in this file.
  3. Let mapped leaves carry a type. The full fix, and much the largest. BodyMapping would need a declared type and projectMappedBody a typed accessor rather than mappedString. Worth its own design pass, and probably not worth doing on the strength of one consumer.

Items 1 and 2 are cheap and would have saved the entire investigation that produced this.

Boundary with #311

Kept deliberately separate so the in-flight work is not disturbed:

  • #311 is about a mapped body being unable to carry an operator-pinned constant, and its proposed fix is to let FixedBody seed the projected map before mappings project onto it. That fix is correct, sufficient for its consumer, and unaffected by this issue. A seeded fixed value is written as its own JSON shape and never passes through mappedString, so #311 lands non-string constants without touching the typing of caller-supplied mapped values.
  • This issue is about caller-supplied mapped values being string-only, and about the absence of any authoring-time signal.

Landing #311 alone leaves this issue's problem entirely in place: constants would gain shapes while every mapped input stayed a string, still with no diagnostic. That is a perfectly reasonable place to stop, and it should be a decision rather than an oversight, which is the reason this is written down.

Not urgent

No production behaviour is wrong. The known consumer is running safely in its restricted form, and this describes a sharp edge for guardfile authors rather than a defect in a running system.

Self-contained. **Does not block #311 and does not change what #311 should do** — see the boundary section at the end. Split out at Kai's direction because #311 is in flight and this is a wider finding that should not ride on it as a comment. ## The limit A `body` block written as mappings projects **strings and only strings** onto the wire, regardless of what the caller supplies or what the upstream expects. `http/opcore/body_mapping.go`, where the model-facing schema is built: ```go f := Field{Name: name, Type: "string", Required: true} ``` and where the outgoing body is assembled: ```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) if err != nil { return nil, err } out[mapping.Target] = value } return json.Marshal(out) } ``` `mappedString` returns a `string`, and that string is what lands at `mapping.Target`. `descriptor.go` states the same intent plainly: "required string input paths projected onto fresh top-level body keys." A dotted source path does not change this. `insertMappingField` builds a nested **input** shape (`Type: "object"` for interior segments), but the value projected onto the wire key is still the leaf string. **So any upstream requiring a non-string at a mapped parameter is unreachable through `map`, in every configuration.** ## Why this is worth its own issue rather than a footnote **Nothing surfaces the restriction at authoring time.** A guardfile that maps a parameter the upstream wants as an object, number, or boolean parses cleanly, builds cleanly, ships, registers its tool, and then fails on every call with an upstream error. Measured against a live upstream (Exa's `/search`), varying only the one parameter: | Sent | Result | | --- | --- | | parameter absent | 200 | | `contents={"text": true}` (object) | 200, content returned | | `contents="text"` (string) | **HTTP 400** | | `contents="true"` (string) | **HTTP 400** | ``` Validation error: Invalid input: expected object, received string at "contents" ``` That 400 is the **first and only** notice the author gets, and it arrives in production against a metered third-party API. The engine knew at parse time that this mapping could only ever emit a string; the upstream contract is the part it cannot know, but the emitted type is not. ## Why it is easy to hit `map` is not an optional stylistic choice. It is the **only** construct that renames an input to a different upstream key, because a body field carrying an upstream alias is rejected outright: ``` input %q sets an upstream name outside query parameters (fail-closed) ``` So any guardfile whose upstream requires a parameter name that collides with `ReservedFlagNames` (`dry-run`, `query`, `output`, `body-file`) is forced into mapped-body mode for that leaf, and mapped-body mode then quietly forbids every non-string parameter on that same leaf, including ones entirely unrelated to the collision that forced the mode. **The two constraints compose into something neither one states.** An author reading either rule in isolation would not predict it. ## What would help, smallest first 1. **Say it in the guardfile-authoring docs.** Today the only ways to learn this are reading `body_mapping.go` or shipping a 400. `docs/specverb-request.md` already covers the reserved-name rules and is the natural home. 2. **Fail at parse time where the intent is visibly non-string.** If a `map` target is given a value shape that cannot be a string, reject it with a message naming the limit rather than deferring to the upstream. This is partial, since the engine cannot know an arbitrary upstream's contract, but it converts the most common authoring mistake from a runtime 400 into a build error, which matches the fail-closed posture elsewhere in this file. 3. **Let mapped leaves carry a type.** The full fix, and much the largest. `BodyMapping` would need a declared type and `projectMappedBody` a typed accessor rather than `mappedString`. Worth its own design pass, and probably not worth doing on the strength of one consumer. Items 1 and 2 are cheap and would have saved the entire investigation that produced this. ## Boundary with #311 Kept deliberately separate so the in-flight work is not disturbed: * **#311** is about a mapped body being unable to carry an operator-**pinned constant**, and its proposed fix is to let `FixedBody` seed the projected map before mappings project onto it. **That fix is correct, sufficient for its consumer, and unaffected by this issue.** A seeded fixed value is written as its own JSON shape and never passes through `mappedString`, so #311 lands non-string constants without touching the typing of caller-supplied mapped values. * **This issue** is about caller-supplied mapped values being string-only, and about the absence of any authoring-time signal. Landing #311 alone leaves this issue's problem entirely in place: constants would gain shapes while every mapped input stayed a string, still with no diagnostic. That is a perfectly reasonable place to stop, and it should be a decision rather than an oversight, which is the reason this is written down. ## Not urgent No production behaviour is wrong. The known consumer is running safely in its restricted form, and this describes a sharp edge for guardfile authors rather than a defect in a running system.
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#312
No description provided.