366 lines
15 KiB
Markdown
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.
|