feat(opcore): let a mapped leaf carry a declared type #321

Merged
coilyco-ops merged 1 commit from aos/claude/sb46-typed-mapped-leaves into main 2026-08-25 16:59:16 +00:00
Owner

Closes #312. This is its item 3, the full fix, after #320 landed items 1 and 2.

What changes

body {
    map "search_text" to="query"
    map "contents"    to="contents"       type="object"
    map "limit"       to="numResults"     type="integer"
    map "domains"     to="includeDomains" type="array" items="string"
}

string, integer, number, boolean, object, array. items= gives an array's element type, and any takes each element as supplied, the union rule an empty swagger items schema implies. An absent type is string, which is what every mapped leaf projected before, so nothing existing moves.

Two halves, and the second is the one that matters

The type reaches the wire. mappedString becomes mappedValue, so contents={"text": true} arrives as an object rather than being unreachable.

A wrong shape is refused before the request fires. That is the whole reason this was worth doing: the upstream's 400 was the first and only notice, and it arrived in production against a metered API. An integer leaf given 1.5 is refused too, so a declared integer cannot arrive as one. Tested by counting upstream calls rather than reading an exit code — a refusal that still fires the request is not a refusal.

The schema said string too

insertMappingField hardcoded Type: "string". So a tool advertised a string input for a leaf about to send an object. That is fixed in the same change, because a model reading the schema and a runtime building the body disagreeing is how the next version of this bug would arrive.

#320's refusal is superseded, not kept

That PR made type= on a map a parse error naming the string limit. It named the limit because there was no way past it. There is now, so a declared shape parses.

What still fails closed: an unsupported type, an items outside an array, an unknown property, and a child node. TestMapShapeRefusalNamesTheStringLimit is replaced by TestMapCarriesADeclaredType and TestMapTypeFailsClosed.

Why now, having said it was not worth doing

The issue said item 3 was "probably not worth doing on the strength of one consumer", and I repeated that when landing #320. Two things changed my read:

  • opcore.CoerceItems already exists, from #317's array work, so array elements reuse the coercion the flag path uses and the two cannot drift about what an array of integers is. The design pass the issue wanted is largely already paid for.
  • coilysiren/inbox#426's exit condition is that each declared boundary binds or refuses. Items 1 and 2 made the limit visible; only item 3 makes the guardfile able to express the constraint at all, which is the umbra half of that epic's thesis.

Verification

make test, make vet, make lint (0 issues), godoc-current.txt regenerated. New tests cover the Exa case end to end across five types, seven wrong-shape refusals each asserting the upstream was never called, the six parse cases, and five parse-time rejections.

Refs coilysiren/inbox#426

🤖 Generated with Claude Code

Closes #312. This is its item 3, the full fix, after #320 landed items 1 and 2. ## What changes ```kdl body { map "search_text" to="query" map "contents" to="contents" type="object" map "limit" to="numResults" type="integer" map "domains" to="includeDomains" type="array" items="string" } ``` string, integer, number, boolean, object, array. `items=` gives an array's element type, and `any` takes each element as supplied, the union rule an empty swagger `items` schema implies. **An absent type is string**, which is what every mapped leaf projected before, so nothing existing moves. ## Two halves, and the second is the one that matters **The type reaches the wire.** `mappedString` becomes `mappedValue`, so `contents={"text": true}` arrives as an object rather than being unreachable. **A wrong shape is refused before the request fires.** That is the whole reason this was worth doing: the upstream's 400 was *the first and only notice*, and it arrived in production against a metered API. An integer leaf given `1.5` is refused too, so a declared integer cannot arrive as one. Tested by counting upstream calls rather than reading an exit code — a refusal that still fires the request is not a refusal. ## The schema said string too `insertMappingField` hardcoded `Type: "string"`. So a tool advertised a string input for a leaf about to send an object. That is fixed in the same change, because a model reading the schema and a runtime building the body disagreeing is how the next version of this bug would arrive. ## #320's refusal is superseded, not kept That PR made `type=` on a `map` a parse error naming the string limit. It named the limit because there was no way past it. There is now, so a declared shape parses. What still fails closed: an unsupported type, an `items` outside an array, an unknown property, and a child node. `TestMapShapeRefusalNamesTheStringLimit` is replaced by `TestMapCarriesADeclaredType` and `TestMapTypeFailsClosed`. ## Why now, having said it was not worth doing The issue said item 3 was *"probably not worth doing on the strength of one consumer"*, and I repeated that when landing #320. Two things changed my read: * `opcore.CoerceItems` already exists, from #317's array work, so array elements reuse the coercion the flag path uses and the two cannot drift about what an array of integers is. The design pass the issue wanted is largely already paid for. * `coilysiren/inbox#426`'s exit condition is that each declared boundary binds or refuses. Items 1 and 2 made the limit *visible*; only item 3 makes the guardfile able to express the constraint at all, which is the umbra half of that epic's thesis. ## Verification `make test`, `make vet`, `make lint` (0 issues), `godoc-current.txt` regenerated. New tests cover the Exa case end to end across five types, seven wrong-shape refusals each asserting the upstream was never called, the six parse cases, and five parse-time rejections. Refs coilysiren/inbox#426 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(opcore): let a mapped leaf carry a declared type
All checks were successful
ci / secrets (pull_request) Successful in 10s
ci / lint (pull_request) Successful in 41s
ci / test (pull_request) Successful in 55s
3239a839da
Item 3 of umbra#312, the full fix. A `body` block written as `map` projected a
string at every leaf whatever the caller supplied, so an upstream requiring an
object, number, or boolean at a mapped parameter was unreachable through `map`
in every configuration.

`map "contents" to="contents" type="object"` now reaches the wire as an object.
Supported types are string, integer, number, boolean, object, and array, with
`items=` for an array's elements and `any` taking each element as supplied, the
union rule an empty swagger items schema implies. An absent type is string,
which is what every mapped leaf projected before, so nothing existing moves.

The declared type reaches the model-facing schema as well as the wire.
insertMappingField hardcoded `Type: "string"`, so a tool advertised a string
input for a leaf that is about to send an object.

A caller supplying the wrong shape is refused before the request fires, which is
the half that matters. The whole reason this was worth fixing is that the
upstream's 400 was the first and only notice, and it arrived in production
against a metered API. An integer leaf given a fraction is refused too, so a
declared integer cannot arrive as one.

The parse-time refusal from #320 is superseded rather than kept. It named the
string limit because there was no way past it; there is now, so a shape on a
`map` parses instead of failing. What still fails closed is an unsupported type,
an `items` outside an array, an unknown property, and a child node.

Array elements go through the same coercion the flag path uses, so the two
cannot disagree about what an array of integers is.

Closes coilyco-flight-deck/umbra#312
Refs coilysiren/inbox#426

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Agent-Role: platform
Sign in to join this conversation.
No reviewers
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!321
No description provided.