feat(specverb): let an action input carry an array, and refuse before the write #319

Merged
coilysiren merged 1 commit from aos/claude/sb46-array-action-inputs into main 2026-08-25 09:48:58 +00:00
Owner

Closes #317.

The blocker, and which parts of it this clears

#317 named three limits that composed into one: fail-when is post-write by construction, required is unavailable on a can leaf, and an action shadow could not carry an array. The shadow was the only route to a pre-write refusal, and the shadow could not pass a label array through to the create call.

This clears the array limit and the required limit. required on a can leaf's body flag is untouched, and stays available as the cheaper shape for a generated leaf that wants to refuse without a shadow at all.

array on an action input

action create issue {
    input repo   { positional; required; help "owner/name" }
    input labels { flag; required; array; help "label id, repeatable" }
    call create issue { args { owner-repo $repo; labels $labels } }
}

The flag becomes repeatable and its values project as a JSON array. The element type is not declared in the guardfile. It is read from the schema of the leaf field the arg binds, so a shadow encodes exactly what the leaf it shadows would:

  • against items: {type: integer}, --labels 199 --labels 333 sends [199, 333] as numbers
  • against that same leaf, --labels headless is refused, not sent
  • against an empty items: {}, each token resolves on its own, the union rule from #315

Flags only. A positional list cannot be told from the arguments that follow it, so array plus positional is a parse error.

required was parsed and never read

bindInputs checked Required for positionals only, so input labels { flag; required } bound nothing and refused nothing. That is fixed here, and it is the half of #317 that actually produces the refusal: the check runs while inputs bind, which is before the request is assembled, so the run ends with the hazard absent rather than reported. fail-when cannot do that job, and this does not change what fail-when is for.

What still carries scalars only

Both fail closed rather than flattening, which is the point:

  • a collect step pages a request built from string bindings, so binding an array input there is a build-time error naming the limit
  • an exec step takes argv tokens, so a list reaching one is refused at the step

Shared encoding

The items: {} union rule from #315 moves to opcore as CoerceItems/AnyItem. It was going to exist twice otherwise, once per path, which is how the two would drift.

API break

v0.x, no deprecation cycle, noted per AGENTS.md. stepflow.Runner's Fire and Plan each take a SliceOf, and Run and PlanCalls each take the list bindings. A Runner over a surface that cannot carry a list passes nil and refuses one that arrives.

Verification

make test, make vet, make lint (0 issues), make tidy, and godoc-current.txt regenerated. Four new tests pin the behaviour: the array reaching the body as typed JSON, the wrong element type being refused before any request fires, the missing required flag refusing before the write, and the collect form failing at build.

Refs coilysiren/inbox#426

🤖 Generated with Claude Code

Closes #317. ## The blocker, and which parts of it this clears #317 named three limits that composed into one: `fail-when` is post-write by construction, `required` is unavailable on a `can` leaf, and an action shadow could not carry an array. The shadow was the only route to a pre-write refusal, and the shadow could not pass a label array through to the create call. This clears the array limit and the `required` limit. `required` on a `can` leaf's body flag is untouched, and stays available as the cheaper shape for a generated leaf that wants to refuse without a shadow at all. ## `array` on an action input ```kdl action create issue { input repo { positional; required; help "owner/name" } input labels { flag; required; array; help "label id, repeatable" } call create issue { args { owner-repo $repo; labels $labels } } } ``` The flag becomes repeatable and its values project as a JSON array. **The element type is not declared in the guardfile.** It is read from the schema of the leaf field the arg binds, so a shadow encodes exactly what the leaf it shadows would: * against `items: {type: integer}`, `--labels 199 --labels 333` sends `[199, 333]` as numbers * against that same leaf, `--labels headless` is **refused**, not sent * against an empty `items: {}`, each token resolves on its own, the union rule from #315 Flags only. A positional list cannot be told from the arguments that follow it, so `array` plus `positional` is a parse error. ## `required` was parsed and never read `bindInputs` checked `Required` for positionals only, so `input labels { flag; required }` bound nothing and refused nothing. That is fixed here, and it is the half of #317 that actually produces the refusal: the check runs while inputs bind, which is **before** the request is assembled, so the run ends with the hazard absent rather than reported. `fail-when` cannot do that job, and this does not change what `fail-when` is for. ## What still carries scalars only Both fail closed rather than flattening, which is the point: * a **`collect`** step pages a request built from string bindings, so binding an `array` input there is a **build-time** error naming the limit * an **exec** step takes argv tokens, so a list reaching one is refused at the step ## Shared encoding The `items: {}` union rule from #315 moves to opcore as `CoerceItems`/`AnyItem`. It was going to exist twice otherwise, once per path, which is how the two would drift. ## API break v0.x, no deprecation cycle, noted per AGENTS.md. `stepflow.Runner`'s `Fire` and `Plan` each take a `SliceOf`, and `Run` and `PlanCalls` each take the list bindings. A Runner over a surface that cannot carry a list passes nil and refuses one that arrives. ## Verification `make test`, `make vet`, `make lint` (0 issues), `make tidy`, and `godoc-current.txt` regenerated. Four new tests pin the behaviour: the array reaching the body as typed JSON, the wrong element type being refused before any request fires, the missing required flag refusing before the write, and the collect form failing at build. Refs coilysiren/inbox#426 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(specverb): let an action input carry an array, and refuse before the write
All checks were successful
ci / test (pull_request) Successful in 47s
ci / lint (pull_request) Successful in 29s
ci / secrets (pull_request) Successful in 10s
faed4be5bf
An `input` may now declare `array`. Its flag becomes repeatable and its values
project as a JSON array, coerced to the element type the bound leaf field's own
schema declares, so an action shadow encodes exactly what the leaf it shadows
would. Flags only: a positional list cannot be told from the arguments after it.

This unblocks a pre-write refusal. #317 named three limits that composed into
one: `fail-when` is post-write by construction, `required` is unavailable on a
`can` leaf, and an action shadow could not carry an array. The shadow was the
only route to a pre-write refusal and could not pass the label array through.

Two of the three are addressed here. The array reaches the call, and `required`
on a flag input is now enforced. It was parsed and then never read: bindInputs
checked Required for positionals only, so `input labels { flag; required }`
bound nothing and refused nothing. The check now runs while inputs bind, which
is before the request is assembled, so the run ends with the hazard absent
rather than reported.

Two forms still carry scalars only, and both fail closed rather than flatten. A
`collect` step pages a request built from string bindings, so binding an array
input there is a build-time error naming the limit. An exec step takes argv
tokens, so a list reaching one is refused at the step.

The `items: {}` union rule from #315 moves to opcore as CoerceItems/AnyItem and
is now shared by the leaf path and the action path, rather than existing twice.

API break (v0.x, no deprecation cycle): stepflow.Runner's Fire and Plan each
take a SliceOf, and Run and PlanCalls each take the list bindings. A Runner over
a surface that cannot carry a list passes nil and refuses one that arrives.

Closes coilyco-flight-deck/umbra#317
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
coilysiren deleted branch aos/claude/sb46-array-action-inputs 2026-08-25 09:48:58 +00:00
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!319
No description provided.