Files
lda-wf/docs/superpowers/specs/2026-08-12-composite-input-expressions-design.md
T

366 lines
15 KiB
Markdown

# Composite Input Expressions Design
Date: 2026-08-12
Status: Current implementation contract for Workflow Console authoring Slice 5.
Related:
- [Atomic step input bindings](2026-07-22-atomic-step-input-bindings-design.md)
- [Workflow Console selected-step dataflow](2026-08-09-workflow-console-selected-step-dataflow-design.md)
- [Current roadmap](../../current_roadmap.md)
- [Tracked product gaps](../../../ISSUES.md)
## Goal
Allow one node input value to be assembled recursively from graph paths and
JSON literals. The motivating case is a `wf.std.concat` input whose first item
comes from workflow state and whose second item is a literal. Authors must not
need synthetic state fields, constant nodes, reducers, or ambiguous local paths
such as `items.0` to construct that value.
The model is a data-construction language, not a computation language. Nodes
continue to own concatenation, arithmetic, branching, and other behavior.
## Existing Boundary
Canonical input bindings currently assign one complete graph-path or literal
value to one node-local target:
```json
{"target": "items", "path": "state.items"}
{"target": "separator", "value": " "}
```
Nested object targets work because `LocalPath` traverses mapping properties.
Array elements do not: interpreting `items.0` as an index would make numeric
object keys ambiguous and turn binding order into mutation semantics.
Existing bindings can therefore supply an entire literal array or an entire
path-backed array, but cannot construct one array or object from independently
sourced members.
## Scope
This slice includes:
- a recursive input-expression model in `wf_core`;
- a new additive expression-bearing input binding;
- runtime resolution for literal, path, array, and object expressions;
- validation, persistence, Workflow API, JSON-RPC, MCP, and CLI round trips;
- TypeScript decoding and console authoring support;
- per-item and per-field Literal/Path controls in schema-backed input editors;
- truthful non-empty schemas for `first_item`, `last_item`, and their public
`wf.std` projections; and
- focused compatibility, limit, error, and end-to-end tests.
This slice excludes:
- arithmetic, interpolation, conditions, transforms, reducers, or capability
calls inside expressions;
- workflow final-output expressions;
- indexed `LocalPath` semantics;
- direct graph gestures; and
- artifact, deployment, or run workflow completion in the console.
The expression types should be reusable by later workflow-output work, but no
workflow-output behavior changes in this slice.
## Canonical Model
The existing `InputBinding` remains the simple path/value union because it is
also reused by final workflow-output projection. Node-local inputs gain a
step-specific additive union:
```text
InputBinding = InputPathBinding | InputValueBinding
StepInputBinding = InputBinding | InputExpressionBinding
```
The new binding contains a local target and one explicitly tagged expression:
```json
{
"target": "request",
"expression": {
"kind": "object",
"fields": {
"items": {
"kind": "array",
"items": [
{"kind": "path", "path": "state.foo"},
{"kind": "literal", "value": "wowcool"}
]
},
"separator": {"kind": "literal", "value": " "}
}
}
}
```
The recursive union is:
```text
InputExpression =
LiteralExpression(kind="literal", value=JsonValue)
| PathExpression(kind="path", path=GraphSourcePath)
| ArrayExpression(kind="array", items=list[InputExpression])
| ObjectExpression(kind="object", fields=dict[str, InputExpression])
```
`kind` is a discriminator. Every model forbids extra fields. Literal values use
the existing strict finite-JSON validation. Object keys are JSON strings and
array order is canonical.
Every node-local payload construction boundary uses `StepInputBinding`:
- `NodeUse.input`;
- `SubgraphNode.input`; and
- `InterruptNode.request`.
Workflow final-output fields continue using `InputBinding`, so this slice does
not accidentally authorize expressions at that separate semantic boundary.
Existing path and value bindings remain the preferred representation for
ordinary whole-field assignments. No migration rewrites existing documents.
## Supported Boundary Matrix
| Boundary | Binding type after this slice | Expressions |
| --- | --- | --- |
| Capability/node-use input | `StepInputBinding` | Accepted |
| Subgraph invocation input | `StepInputBinding` | Accepted |
| Interrupt request payload | `StepInputBinding` | Accepted |
| Final workflow output | `InputBinding` | Rejected |
| Compatibility input/output maps | Path/value maps | Rejected as lossy |
All focused API, JSON-RPC, MCP, and CLI operations that create or replace one
of the first three node-local boundaries accept `StepInputBinding`.
Capability add/update and complete step-input replacement therefore accept
expressions. Operations that replace final workflow-output bindings remain
typed as `InputBinding`. OpenRPC and the transport-neutral manifest expose that
distinction; a broad global replacement of `InputBinding` is incorrect.
The persisted draft payload models follow the same boundary. In particular,
`DraftUseStep.input`, `DraftSubgraphPayload.input`, and
`DraftInterruptPayload.request` migrate to `StepInputBinding`, while draft and
compiled workflow final-output fields remain `InputBinding`. This prevents
artifact parsing from rejecting canonical expressions before compilation.
## Resolution Semantics
Resolution is pure and deterministic. One shared core resolver is used by
normal node execution, subgraph invocation, and interrupt request projection:
- literal returns its strict JSON value;
- path reads workflow input, state, or context through the existing safe graph
path resolver;
- array resolves each item in order; and
- object resolves each named field recursively.
The resolved value is assigned once at the expression binding's local target.
Expressions never mutate an intermediate local payload. Existing target overlap
validation applies across all three binding shapes, so an expression owning
`request` cannot coexist with another binding targeting `request.title`.
A failure identifies the node, binding target, and expression location, for
example `request.items[0]`. Missing graph paths remain typed input-resolution
failures. The final resolved node payload still passes through the capability's
input JSON Schema before invocation.
## Validation
Validation is schema-directed when the target schema is known. The existing
object-only schema-path helper is expanded into one bounded utility shared by
draft projection and console schema projection. It traverses object
`properties`, schema-valued `additionalProperties`, homogeneous array `items`,
tuple `prefixItems`, and bounded local `#/$defs/...` and
`#/definitions/...` references. Unsupported composition keywords fail closed
with a diagnostic identifying the schema location.
Within that supported subset:
- literal values validate against the current expression position;
- path source schemas are checked against the target position when both are
available and statically comparable;
- array members validate against `items` or the applicable tuple position;
- object members validate against declared properties;
- required properties, `minItems`, and `maxItems` are enforced when declared;
- `$ref` and `$defs` resolve through the existing bounded local-reference
resolver; and
- unknown fields follow `additionalProperties` rather than an invented UI rule.
Known incompatible source and target schemas reject the mutation. A path whose
source has no authoring-time schema, including dynamic context paths, is accepted
with an explicit deferred-runtime validation status; the final resolved payload
remains authoritative at execution. When both source and target schemas are
known, unions and references are compared only after successful bounded
normalization. If that known-schema comparison uses unsupported constructs or
cannot establish compatibility safely, authoring fails closed rather than
guessing.
Some capabilities intentionally accept empty arrays. `SequenceInput` therefore
remains unchanged. `first_item` and `last_item` instead receive a dedicated
non-empty input model using `Field(min_length=1)`. Their runtime guards remain
defensive. Empty-aware operations continue to use `SequenceInput`.
The service validates expressions before mutation. Runtime validation remains
the final authority for dynamic path values whose concrete data cannot be known
during authoring.
## Safety Limits
Expression parsing and resolution enforce explicit limits aligned with the
existing bounded JSON contracts. A dedicated root parser walks the submitted
tree before Pydantic union validation and is reused before runtime resolution:
- maximum recursive container depth: 64;
- maximum total expression nodes: 1,024;
- nested containers inside literal JSON values count toward the same depth and
container budget;
- existing JSON response/request byte limits remain unchanged; and
- cyclic model references may describe data recursively, but submitted
expression documents are finite JSON trees.
Limit errors are validation errors, not recursion crashes or partially applied
draft mutations.
## API And Transport Behavior
Focused step-input replacement changes from `InputBinding` to
`StepInputBinding` and remains the mutation seam. No second composite
mutation operation is added. The new binding must round-trip through:
- `WorkflowApiSurface` and its service implementation;
- JSON-RPC request models, OpenRPC, and the contract manifest;
- the remote Python client;
- the legacy MCP workflow surface;
- CLI bindings files; and
- the generated TypeScript contract plus authored browser decoder.
Canonical operation behavior is explicit:
- complete node-local replacement preserves and accepts expressions;
- focused node-local creation/update accepts expressions when it accepts a
complete canonical binding list;
- compatibility map merge/upsert rejects expressions it cannot represent; and
- legacy draft normalization preserves canonical expression records after
normalizing deprecated fields, but deprecated maps cannot create them.
CLI inline flags remain optimized for simple path/value bindings. Composite
expressions use the canonical bindings JSON file initially; a compact inline
expression syntax is explicitly out of scope.
Lossy compatibility map helpers must reject expression-bearing canonical lists
they cannot reproduce exactly. They must not flatten expressions or silently
discard nested source information.
## Console Interaction
The selected-step Inputs inspector keeps the current outer binding-row model
and adds a recursive editor state:
```text
ExpressionEditorState =
LiteralState(value, touched)
| PathState(path, touched)
| ArrayState(items: ExpressionEditorState[])
| ObjectState(fields: ordered entries of name + ExpressionEditorState)
```
Projection from canonical bindings to editor state and serialization back are
pure functions with round-trip tests. An expression the current editor cannot
represent remains visible as an unsupported canonical record and blocks
replacement; it is never replaced with an empty literal or raw-JSON fallback.
Because this mandated editor union stores path values as strings, projection
normalizes structural path objects through the canonical path formatter. The
editor round-trip contract is therefore semantic path equality plus deterministic
string serialization; the Task 5 canonical copy helpers remain representation-
lossless outside the editor.
Core models and transports round-trip expressions for capability, subgraph, and
interrupt-request inputs in this slice. The first console editor is deliberately
narrower: it appears in the selected capability step's Inputs inspector. Later
typed subgraph and interrupt editors reuse the same projection model in Slice 8.
Until then, the console preserves their canonical expressions without offering
a lossy specialized editor.
For a schema field, the author chooses one of:
- Path;
- Literal; or
- Construct, when the field schema is an array or object.
Constructed arrays show ordered items. Each item independently chooses Path,
Literal, or another Construct when its schema permits nesting. Authors can add,
remove, and reorder items. Constructed objects show schema properties; each
property independently selects its source. Required fields are visibly marked.
When `additionalProperties` is a schema, authors may add and remove named fields
using that schema. Boolean `additionalProperties: true` uses a bounded generic
JSON literal/path editor; `false` forbids new names.
The form emits one expression binding for the composite field, never synthetic
targets such as `items.0`. Existing simple rows remain unchanged. Unsupported
schema composition fails closed with a readable explanation and preserves raw
canonical data rather than coercing it into a weaker editor.
For a non-empty schema, removing the final item leaves the form invalid and
explains the cardinality requirement. For schemas that permit empty arrays,
submitting an empty constructed array remains valid.
## Compatibility Audit
Every branch over node-local `StepInputBinding` must become exhaustive. Code
that intentionally handles only simple `InputBinding`, especially workflow
final-output projection, must remain narrow and reject expression records. The
audit includes:
- core validation and runtime node/subgraph resolution;
- draft schema projection and mutation helpers;
- compatibility map merge/rebuild code;
- artifact parsing and persistence;
- API/RPC/MCP request and response models;
- CLI parsing, rendering, and explain guidance;
- contract generation and TypeScript decoders; and
- console projections, copy helpers, forms, and graph summaries.
Unknown binding variants fail closed. Existing persisted path/value bindings
must retain byte-equivalent canonical JSON after parse and serialization where
the current serializers already guarantee it.
Expression strictness applies after existing legacy field normalization.
Expressions are accepted only through canonical binding lists; deprecated
`input_map`, `input_values`, and equivalent compatibility payloads cannot
encode or synthesize them.
## Testing
Tests proceed from the core outward:
1. model parsing, strict JSON, discriminators, limits, and serialization;
2. recursive runtime resolution for arrays, objects, and nested combinations;
3. exact failure paths for missing sources and malformed expressions;
4. target-overlap and schema compatibility validation;
5. revision-checked draft replacement and persistence round trips;
6. API, RPC, client, MCP, CLI, OpenRPC, manifest, and TypeScript parity;
7. console projection and generated-form interaction tests;
8. `first_item` versus `first_item_maybe` cardinality behavior; and
9. an end-to-end `wf.std.concat` draft with a state-backed first item, literal
second item, and literal separator.
Regression coverage proves old path/value workflows execute unchanged and
compatibility helpers reject, rather than corrupt, expression bindings.
## Success Criteria
The slice is complete when an author can configure and persist this input from
the console and canonical APIs:
```text
items = [path(state.foo), literal("wowcool")]
separator = literal(" ")
```
The resulting `wf.std.concat` node receives the correctly ordered array, while
all existing simple bindings remain valid. No synthetic state, constants,
reducers, indexed local paths, or raw draft patches are required.