feat(opcore): let a mapped body carry pinned constants #313

Merged
coilysiren merged 1 commit from task/mapped-body-pins into main 2026-08-19 16:34:15 +00:00
Member

Closes #311.

The issue's analysis was right, and its fix was not sufficient

The diagnosis in the issue and its follow-up comment both hold up: map is
the only construct that renames, set refused to combine with it, and 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.

So this takes the smaller of the two proposals. set seeds the map the
mappings project onto, and a key that is both pinned and mapped fails closed
rather than picking a silent winner. The BodyFlags prohibition stays: two
model-supplied values colliding is a real ambiguity, a pin and a mapping is not.

But that alone does not unblock the consumer. The issue expected the
guardfile to then carry set contents={text:#true} beside its map. That
syntax does not exist: a KDL property holds a scalar, and the parameter that
motivated this takes an object. Verified against the parser -

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

So set also gains a block form: 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 the issue's 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 now works end to end, asserted through the parser and onto the
wire in TestInlineMappedBodyWithPinnedObjectReachesTheWire:

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. That is the property the construct exists for.

Two hand-rolled body assemblers folded in

assembleBody was one of three places that ordered the body modes. The other
two short-circuited on FixedBody, so with set + map legal they would have
sent the pins without the mapped keys:

  • the action dry-run planner now previews through opcore.AssembleBody, so a preview cannot disagree with the request it previews
  • the CLI refuses a mapped-body grant outright. Mapped sources mount no CLI flag (request.go builds flags from BodyFlags only), so that surface can never fill one, and refusing beats sending a partial body. Pre-existing: a mapped-body grant already sent an empty body there.

A test that was passing on nothing

TestParseInlineBodyMappingsRejectOtherBodyModes/fixed_body asserted exactly
the rule this PR changes, and kept passing after I removed the rule. It was
failing on a KDL lex error, a bare true needing to be #true, so it had
never tested the body-mode rejection at all.

It is replaced by the pinned-key/map-target collision case, and the new
fail-closed table asserts the error is not a parse KDL error, so this
particular way of passing on nothing cannot recur.

Verification

  • The new execution tests fail against main with fixed body and body mappings cannot be combined (fail-closed), the error quoted in the issue.
  • Every fail-closed case was checked to refuse on its own rule rather than on syntax.
  • make godoc-update regenerated godoc-current.txt for the new exported AssembleBody.
  • go build, go vet, go test ./..., golangci-lint run ./..., and pre-commit run --all-files are green.

One note on docs

docs/opcore-inline.md was at 3993 chars against a 4000 cap, so the new
construct did not fit. Body projection and pins moved to
docs/opcore-body.md, which is what the hook's own message advises and leaves
the grammar page at 3896. Nothing was dropped, only relocated and linked from
FEATURES.md.

Downstream

coilyco-bridge/deploy#741 is correcting the consumer comment. Once this
lands, the "one-line change" claim it retracts becomes true again in the
set-beside-map form, and the guardfile can pin contents without opening
a model-facing input.

Closes #311. ## The issue's analysis was right, and its fix was not sufficient The diagnosis in the issue and its follow-up comment both hold up: `map` is the only construct that renames, `set` refused to combine with it, and 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`. So this takes the smaller of the two proposals. `set` seeds the map the mappings project onto, and a key that is both pinned and mapped fails closed rather than picking a silent winner. The `BodyFlags` prohibition stays: two model-supplied values colliding is a real ambiguity, a pin and a mapping is not. **But that alone does not unblock the consumer.** The issue expected the guardfile to then carry `set contents={text:#true}` beside its `map`. That syntax does not exist: a KDL property holds a scalar, and the parameter that motivated this takes an object. Verified against the parser - set contents={text:#true} parse error at 3:67: expected value, got { So `set` also gains a block form: 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 the issue's 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 now works end to end, asserted through the parser and onto the wire in `TestInlineMappedBodyWithPinnedObjectReachesTheWire`: ```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. That is the property the construct exists for. ## Two hand-rolled body assemblers folded in `assembleBody` was one of three places that ordered the body modes. The other two short-circuited on `FixedBody`, so with `set` + `map` legal they would have sent the pins without the mapped keys: * the action **dry-run planner** now previews through `opcore.AssembleBody`, so a preview cannot disagree with the request it previews * the **CLI** refuses a mapped-body grant outright. Mapped sources mount no CLI flag (`request.go` builds flags from `BodyFlags` only), so that surface can never fill one, and refusing beats sending a partial body. Pre-existing: a mapped-body grant already sent an empty body there. ## A test that was passing on nothing `TestParseInlineBodyMappingsRejectOtherBodyModes/fixed_body` asserted exactly the rule this PR changes, and kept passing after I removed the rule. It was failing on a KDL lex error, a bare `true` needing to be `#true`, so it had never tested the body-mode rejection at all. It is replaced by the pinned-key/map-target collision case, and the new fail-closed table asserts the error is not a `parse KDL` error, so this particular way of passing on nothing cannot recur. ## Verification * The new execution tests fail against `main` with `fixed body and body mappings cannot be combined (fail-closed)`, the error quoted in the issue. * Every fail-closed case was checked to refuse on its own rule rather than on syntax. * `make godoc-update` regenerated `godoc-current.txt` for the new exported `AssembleBody`. * `go build`, `go vet`, `go test ./...`, `golangci-lint run ./...`, and `pre-commit run --all-files` are green. ## One note on docs `docs/opcore-inline.md` was at 3993 chars against a 4000 cap, so the new construct did not fit. Body projection and pins moved to `docs/opcore-body.md`, which is what the hook's own message advises and leaves the grammar page at 3896. Nothing was dropped, only relocated and linked from `FEATURES.md`. ## Downstream `coilyco-bridge/deploy#741` is correcting the consumer comment. Once this lands, the "one-line change" claim it retracts becomes true again in the `set`-beside-`map` form, and the guardfile can pin `contents` without opening a model-facing input.
feat(opcore): let a mapped body carry pinned constants
All checks were successful
ci / secrets (pull_request) Successful in 9s
ci / lint (pull_request) Successful in 34s
ci / test (pull_request) Successful in 47s
79e3e2f251
A guardfile needing both a renamed input and a fixed upstream parameter
could express neither together. `map` is the only construct that renames,
and it refused to combine with `set`, so the operator's choice was to hand
the key to the model or leave it absent. That is the worst of the three
outcomes for a parameter family that drives cost.

`set` now seeds the body a mapping projects. The two do not conflict:
projectMappedBody builds a fresh map, so pins seed it before the mappings
project onto it. A key that is both pinned and mapped fails closed rather
than picking a silent winner. The body-fields prohibition is untouched,
that one being a real ambiguity between two model-supplied values.

Pinning alone was not enough to unblock the case. A KDL property holds a
scalar, so `set contents={text:#true}` is not expressible and the parameter
that motivated this takes an object. `set` therefore gains a block form:
one node per key, one argument, several for an array, or a nested block for
an object. A pinned key still never enters the input schema, which is the
property separating a pin from a field.

Two hand-rolled body assemblers are folded into the engine so they cannot
order the modes differently: the action dry-run planner now previews what it
would send, and the CLI refuses a mapped-body grant instead of sending a
body missing its mapped keys, mapped sources mounting no CLI flag.

TestParseInlineBodyMappingsRejectOtherBodyModes/fixed_body asserted the rule
this changes and passed on a KDL syntax error rather than on the rule, a
bare `true` lexing as an identifier. It is replaced by the collision case,
which fails for the reason it names.

Closes #311

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>
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!313
No description provided.