Files
lda-wf/docs/historical/superpowers/plans/2026-07-22-atomic-step-input-bindings.md
T

1104 lines
37 KiB
Markdown

# Atomic Step Input Bindings Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add one revision-checked operation that atomically replaces a capability step's canonical input-binding list, including nested graph-path fan-out and literal JSON values.
**Architecture:** Reuse the existing `InputPathBinding | InputValueBinding` core model from API through transport. Put capability-aware target validation and input/state schema projection in `WorkflowDraftAuthoringApi`, keep JSON Schema traversal in `wf_api.schema_projection`, and retain the old map operation only as a compatibility adapter. The CLI lowers either repeatable convenience flags or a canonical JSON file into the same binding list.
**Tech Stack:** Python 3.14, Pydantic 2, JSON Schema Draft 2020-12, Typer, FastAPI JSON-RPC, FastMCP, pytest, Ruff, basedpyright.
## Global Constraints
- Do not add a persisted `CompositeBinding` or any second binding language.
- Preserve canonical binding order and permit one source to feed several distinct targets.
- Reject duplicate and ancestor/descendant local targets before mutation.
- Validate literals against the selected capability-input subschema.
- Project missing `input.*` and `state.*` schemas atomically; never project `context.*`.
- Envelope validation precedes revision checking; stale revision then precedes workspace/catalog semantics.
- Changed replacements advance exactly one revision; exact replacements are revision-checked no-ops.
- Keep the legacy map-only API/RPC and `--merge` behavior operational for real callers.
- Do not change step outputs, workflow outputs, revision history, TypeScript RPC, or runtime binding models.
- Add docstrings/comments around whole-payload projection, schema-reference preservation, and CLI compatibility dispatch.
---
### Task 1: Shared Schema Fragment Selection And Literal Validation
**Files:**
- Modify: `src/wf_api/schema_projection.py`
- Test: `tests/wf_api/test_schema_projection.py`
**Interfaces:**
- Consumes: existing `_schema_at_path`, `_resolve_local_reference`, and definition merge helpers.
- Produces:
```python
def schema_fragment_at_path(
schema: JsonObject,
parts: Sequence[str],
*,
label: str = "schema",
) -> JsonObject: ...
def validate_json_value_at_schema_path(
*,
schema: JsonObject,
parts: Sequence[str],
value: object,
label: str,
) -> None: ...
```
- Extends: `project_schema_path_to_schema_path` accepts an empty `source_parts` tuple to project the complete source schema for whole-payload target `.`.
- [x] **Step 1: Write failing schema-fragment tests**
Add tests covering an inline nested field, a selected `$ref` leaf that needs
root `$defs`, the root fragment for `parts=()`, and a remote selected ref:
```python
def test_schema_fragment_preserves_defs_for_selected_reference() -> None:
fragment = schema_fragment_at_path(
{
"type": "object",
"properties": {"request": {"$ref": "#/$defs/Request"}},
"$defs": {
"Request": {
"type": "object",
"properties": {"format": {"type": "string"}},
}
},
},
("request",),
label="capability input schema",
)
assert fragment["$ref"] == "#/$defs/Request"
assert fragment["$defs"]["Request"]["properties"]["format"] == {
"type": "string"
}
```
```python
def test_schema_fragment_accepts_whole_schema() -> None:
schema = {"type": "object", "properties": {"title": {"type": "string"}}}
assert schema_fragment_at_path(schema, ()) == schema
```
- [x] **Step 2: Write failing literal-validation tests**
Pin valid/invalid strings, objects, arrays, and `null`, including a selected
schema behind a local reference:
```python
def test_validate_json_value_at_nested_schema_path() -> None:
schema = {
"type": "object",
"properties": {"request": {"$ref": "#/$defs/Request"}},
"$defs": {
"Request": {
"type": "object",
"properties": {"format": {"enum": ["markdown", "json"]}},
}
},
}
validate_json_value_at_schema_path(
schema=schema,
parts=("request", "format"),
value="markdown",
label="bindings[0].value",
)
with pytest.raises(
ValueError,
match=r"bindings\[0\]\.value does not satisfy schema at 'request.format'",
):
validate_json_value_at_schema_path(
schema=schema,
parts=("request", "format"),
value="html",
label="bindings[0].value",
)
```
- [x] **Step 3: Write a failing whole-schema projection test**
```python
def test_project_schema_path_accepts_whole_source_schema() -> None:
projected = project_schema_path_to_schema_path(
target_schema={"type": "object", "properties": {}},
source_schema={
"type": "object",
"properties": {"title": {"type": "string"}},
"required": ["title"],
},
source_parts=(),
target_parts=("payload",),
)
assert projected["properties"]["payload"]["required"] == ["title"]
```
- [x] **Step 4: Run the focused tests and confirm RED**
Run:
```bash
uv run pytest tests/wf_api/test_schema_projection.py -q
```
Expected: failures because the two public helpers do not exist and whole
source projection rejects an empty source path.
- [x] **Step 5: Implement fragment selection and literal validation**
Import JSON Schema's instance-validation error separately from schema errors,
then add:
```python
from jsonschema import Draft202012Validator, SchemaError, ValidationError
def schema_fragment_at_path(
schema: JsonObject,
parts: Sequence[str],
*,
label: str = "schema",
) -> JsonObject:
"""Return a self-contained selected schema fragment with local defs."""
_check_schema(label, schema)
fragment = deepcopy(_schema_at_path(schema, parts, label=label))
_merge_definition_block(
fragment,
schema,
"$defs",
target_label=f"{label} fragment",
source_label=label,
)
_merge_definition_block(
fragment,
schema,
"definitions",
target_label=f"{label} fragment",
source_label=label,
)
_check_schema(f"{label} fragment", fragment)
return fragment
def validate_json_value_at_schema_path(
*,
schema: JsonObject,
parts: Sequence[str],
value: object,
label: str,
) -> None:
"""Validate one known literal against a selected schema path."""
fragment = schema_fragment_at_path(schema, parts, label="capability input schema")
path = ".".join(parts) or "."
try:
Draft202012Validator(fragment).validate(value)
except ValidationError as exc:
raise ValueError(
f"{label} does not satisfy schema at {path!r}: {exc.message}"
) from exc
```
In `project_schema_path_to_schema_path`, replace the empty-source rejection
with root selection:
```python
source_value = (
source_schema
if not source_parts
else _schema_at_path(source_schema, source_parts, label="source schema")
)
```
Keep the existing empty-target rejection. Deep-copy `source_value` and merge
definition blocks exactly as today. Extend `_merge_definition_block` with
keyword-only `source_label` and `target_label` parameters whose defaults
preserve current projection error wording; fragment selection passes the
capability-schema labels shown above.
```python
def _merge_definition_block(
target_schema: JsonObject,
source_schema: JsonObject,
key: str,
*,
target_label: str = "state_schema",
source_label: str = "output_schema",
) -> None:
source_defs = source_schema.get(key)
if source_defs is None:
return
if not isinstance(source_defs, dict):
raise ValueError(f"{source_label}.{key} must be an object")
target_defs = target_schema.setdefault(key, {})
if not isinstance(target_defs, dict):
raise ValueError(f"{target_label}.{key} must be an object")
for name, definition in source_defs.items():
if name in target_defs and target_defs[name] != definition:
raise ValueError(f"conflicting {key}.{name}")
target_defs[name] = deepcopy(definition)
```
- [x] **Step 6: Run focused tests and quality checks**
Run:
```bash
uv run pytest tests/wf_api/test_schema_projection.py -q
uv run ruff check src/wf_api/schema_projection.py tests/wf_api/test_schema_projection.py
uv run ruff format --check src/wf_api/schema_projection.py tests/wf_api/test_schema_projection.py
uv run basedpyright --level error src/wf_api/schema_projection.py
```
Expected: all pass with no diagnostics.
- [x] **Step 7: Commit Task 1**
```bash
git add src/wf_api/schema_projection.py tests/wf_api/test_schema_projection.py
git commit -m "feat: validate values at schema paths"
```
---
### Task 2: Atomic Capability-Aware Input Replacement
**Files:**
- Modify: `src/wf_api/draft_authoring.py`
- Modify: `src/wf_api/surface.py`
- Modify: `src/wf_api/service.py`
- Test: `tests/wf_api/test_drafts_service.py`
**Interfaces:**
- Consumes: `schema_fragment_at_path`, `validate_json_value_at_schema_path`, `project_schema_path_to_schema_path`, `has_overlapping_paths`, canonical `InputBinding` models.
- Produces: `WorkflowApiSurface.set_step_input_bindings(...)` with the exact signature from the design spec.
- [x] **Step 1: Write failing structured-assembly and fan-out API tests**
Reuse the nested report capability fixture from the preceding nested-binding
slice. Add one test that replaces a step input list with two nested path
bindings and one literal, then inspects canonical order:
```python
result = await api.set_step_input_bindings(
workspace_id="structured_input",
revision=1,
step_id="report",
bindings=[
InputPathBinding(
path=GraphSourcePath.state("report", "title"),
target=LocalPath.of("request", "title"),
),
InputPathBinding(
path=GraphSourcePath.state("report", "markdown"),
target=LocalPath.of("request", "body"),
),
InputValueBinding(
target=LocalPath.of("request", "format"),
value="markdown",
),
],
)
assert result["revision"] == 2
assert inspected["draft"]["steps"]["report"]["input"] == [
{"target": "request.title", "path": "state.report.title"},
{"target": "request.body", "path": "state.report.markdown"},
{"target": "request.format", "value": "markdown"},
]
```
Add a separate fan-out assertion using the same `state.report.title` source
for `request.title` and `audit.title`.
- [x] **Step 2: Write failing semantic-error and no-mutation tests**
Add parameterized tests for missing target, duplicate target,
ancestor/descendant overlap, invalid literal, unsupported remote target ref,
and non-capability step. Snapshot the workspace before each call and assert it
is byte-for-byte unchanged afterward.
Add stale-revision cases paired with missing target and invalid literal; both
must return `revision_conflict` before those semantic errors.
- [x] **Step 3: Write failing projection, whole-payload, context, and no-op tests**
Cover:
```python
bindings=[
InputPathBinding(path="input.payload", target="."),
]
```
with a missing `input.payload` schema projected from the complete capability
input schema. Add input/state multi-projection, a `context.prior_outcome`
binding that changes no workflow schema, explicit valid `null`, and an exact
second replacement that leaves the revision unchanged.
- [x] **Step 4: Run API tests and confirm RED**
Run:
```bash
uv run pytest tests/wf_api/test_drafts_service.py -k "step_input_bindings" -q
```
Expected: failures because the surface and implementation method do not exist.
- [x] **Step 5: Implement the semantic operation**
Add imports for `InputPathBinding`, `InputValueBinding`,
`has_overlapping_paths`, and the Task 1 schema helpers. Add this method to
`WorkflowDraftAuthoringApi`:
```python
async def set_step_input_bindings(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
bindings: Sequence[InputBinding],
) -> dict[str, Any]:
"""Replace one capability step's canonical input bindings atomically."""
checked = self._workspace_if_revision_matches(
workspace_id=workspace_id,
revision=revision,
)
if isinstance(checked, dict):
return checked
workspace = checked
step = draft_step(workspace.draft, step_id)
capability_name = step.get("use")
if not isinstance(capability_name, str) or not capability_name:
raise ValueError(f"draft step {step_id!r} does not declare a capability use")
spec = self.context.specs.get_qualified_spec(capability_name)
capability_schema = (
spec.input_schema_contract or spec.input_model.model_json_schema()
)
targets = [binding.target for binding in bindings]
if has_overlapping_paths(targets):
raise _overlapping_input_targets_error(bindings)
projected_input = _draft_schema(workspace.draft, "input_schema")
projected_state = _draft_schema(workspace.draft, "state_schema")
for index, binding in enumerate(bindings):
target_parts = binding.target.parts
try:
schema_fragment_at_path(
capability_schema,
target_parts,
label="capability input schema",
)
except ValueError as exc:
raise ValueError(
f"bindings[{index}].target {str(binding.target)!r} "
f"is not declared by capability {capability_name!r}: {exc}"
) from exc
if isinstance(binding, InputValueBinding):
if not target_parts and not isinstance(binding.value, Mapping):
raise ValueError(
f"bindings[{index}].value for target '.' must be a JSON object"
)
validate_json_value_at_schema_path(
schema=capability_schema,
parts=target_parts,
value=binding.value,
label=f"bindings[{index}].value",
)
continue
if isinstance(binding, InputPathBinding):
source = binding.path
if source.root == "context":
continue
target_schema = (
projected_input if source.root == "input" else projected_state
)
if not schema_path_exists(target_schema, source.parts):
target_schema = project_schema_path_to_schema_path(
target_schema=target_schema,
source_schema=capability_schema,
source_parts=target_parts,
target_parts=source.parts,
allow_existing_equivalent=True,
)
if source.root == "input":
projected_input = target_schema
else:
projected_state = target_schema
payload = [binding.model_dump(mode="json") for binding in bindings]
if (
step.get("input", []) == payload
and workspace.draft.get("input_schema", {}) == projected_input
and workspace.draft.get("state_schema", {}) == projected_state
):
return summarize_draft_workspace(workspace)
patch = _step_input_bindings_patch(
workspace=workspace,
step_id=step_id,
bindings=payload,
input_schema=projected_input,
state_schema=projected_state,
)
return await self.drafts.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=patch,
)
```
Implement `_draft_schema`, `_overlapping_input_targets_error`, and
`_step_input_bindings_patch` as focused private helpers in the same module.
`_overlapping_input_targets_error` must report both binding indexes by finding
the first pair for which `paths_overlap(left.target, right.target)` is true.
`_step_input_bindings_patch` emits schema replacements only when changed and
always emits one step-input replacement.
```python
def _draft_schema(draft: Mapping[str, Any], key: str) -> dict[str, Any]:
value = draft.get(key, {})
if not isinstance(value, dict):
raise ValueError(f"draft {key} must be an object")
return deepcopy(value)
def _overlapping_input_targets_error(
bindings: Sequence[InputBinding],
) -> ValueError:
for left_index, left in enumerate(bindings):
for right_index in range(left_index + 1, len(bindings)):
right = bindings[right_index]
if paths_overlap(left.target, right.target):
return ValueError(
f"bindings[{left_index}].target {str(left.target)!r} "
f"overlaps bindings[{right_index}].target "
f"{str(right.target)!r}"
)
raise AssertionError("overlap error requested without overlapping targets")
def _step_input_bindings_patch(
*,
workspace: WorkflowDraftWorkspace,
step_id: str,
bindings: list[dict[str, Any]],
input_schema: dict[str, Any],
state_schema: dict[str, Any],
) -> list[dict[str, Any]]:
patch: list[dict[str, Any]] = []
for key, value in (
("input_schema", input_schema),
("state_schema", state_schema),
):
if workspace.draft.get(key, {}) != value:
patch.append({"op": "replace", "path": f"/{key}", "value": value})
patch.append(
{
"op": "replace",
"path": f"/steps/{escape_json_pointer(step_id)}/input",
"value": bindings,
}
)
return patch
```
- [x] **Step 6: Add the protocol-neutral delegation**
Add the exact method signature to `WorkflowApiSurface` and delegate from
`WorkflowApi`:
```python
async def set_step_input_bindings(
self,
*,
workspace_id: str,
revision: int,
step_id: str,
bindings: Sequence[InputBinding],
) -> dict[str, Any]:
return await self.draft_authoring.set_step_input_bindings(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
bindings=bindings,
)
```
- [x] **Step 7: Run focused API and runtime tests**
Run:
```bash
uv run pytest tests/wf_api/test_drafts_service.py -k "step_input_bindings or nested" -q
uv run pytest tests/core/test_nested_mappings.py tests/core/test_canonical_node_bindings.py -q
uv run ruff check src/wf_api/draft_authoring.py src/wf_api/surface.py src/wf_api/service.py tests/wf_api/test_drafts_service.py
uv run basedpyright --level error src/wf_api/draft_authoring.py src/wf_api/surface.py src/wf_api/service.py
```
Expected: all pass. Include one API test that compiles and executes the draft,
asserting the handler receives `request.title`, `request.body`, and
`request.format` in one object.
- [x] **Step 8: Commit Task 2**
```bash
git add src/wf_api/draft_authoring.py src/wf_api/surface.py src/wf_api/service.py tests/wf_api/test_drafts_service.py
git commit -m "feat: replace draft step input bindings"
```
---
### Task 3: JSON-RPC Model, Method, And Remote Client
**Files:**
- Modify: `src/wf_transport_rpc_http/models.py`
- Modify: `src/wf_transport_rpc_http/methods/drafts.py`
- Modify: `src/wf_transport_rpc_http/client/drafts.py`
- Test: `tests/wf_transport_rpc_http/test_app.py`
- Test: `tests/wf_transport_rpc_http/test_client.py`
**Interfaces:**
- Consumes: `WorkflowApiSurface.set_step_input_bindings` from Task 2.
- Produces: JSON-RPC method `workflow.draft_workspaces.set_step_input_bindings` and matching `RpcDraftClientMixin` method.
- [x] **Step 1: Write failing RPC model and application tests**
Add an application test that sends this request and then inspects the stored
order:
```python
result = await _rpc(
client,
"workflow.draft_workspaces.set_step_input_bindings",
{
"workspace_id": "focused_ws",
"revision": 3,
"step_id": "call",
"bindings": [
{"path": "input.value", "target": "payload.value"},
{"path": "input.value", "target": "audit.value"},
{"value": None, "target": "payload.optional"},
],
},
)
assert result["result"]["revision"] == 4
```
Add malformed union tests for a binding with both `path` and `value`, and one
with neither.
- [x] **Step 2: Write a failing remote-client serialization test**
```python
await client.set_step_input_bindings(
workspace_id="client_ws",
revision=2,
step_id="call",
bindings=[
InputPathBinding(path="state.title", target="request.title"),
InputValueBinding(target="request.format", value="markdown"),
],
)
assert calls[-1] == (
"workflow.draft_workspaces.set_step_input_bindings",
{
"workspace_id": "client_ws",
"revision": 2,
"step_id": "call",
"bindings": [
{"target": "request.title", "path": "state.title"},
{"target": "request.format", "value": "markdown"},
],
},
)
```
- [x] **Step 3: Run transport tests and confirm RED**
Run:
```bash
uv run pytest tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py -k "step_input_bindings" -q
```
Expected: failures for the missing params model, method registration, and
client method.
- [x] **Step 4: Implement the RPC params, method, and client**
In `models.py`, reuse the core union:
```python
class SetStepInputBindingsParams(RpcParamsModel):
workspace_id: str = Field(min_length=1)
revision: int = Field(ge=1)
step_id: str = Field(min_length=1)
bindings: list[InputBinding]
```
Register the method next to the map compatibility method:
```python
@entrypoint.method(
name="workflow.draft_workspaces.set_step_input_bindings",
errors=[WorkflowRpcError],
)
async def workflow_draft_workspaces_set_step_input_bindings(
params: SetStepInputBindingsParams = RpcParams(),
) -> dict[str, Any]:
try:
return await server.api.set_step_input_bindings(
workspace_id=params.workspace_id,
revision=params.revision,
step_id=params.step_id,
bindings=params.bindings,
)
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc)
```
Add the client method using `model_dump(mode="json")` for every binding.
- [x] **Step 5: Run transport verification**
Run:
```bash
uv run pytest tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py -q
uv run ruff check src/wf_transport_rpc_http tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py
uv run basedpyright --level error src/wf_transport_rpc_http
```
Expected: all pass.
- [x] **Step 6: Commit Task 3**
```bash
git add src/wf_transport_rpc_http tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py
git commit -m "feat: expose input bindings over rpc"
```
---
### Task 4: MCP Canonical Binding Tool
**Files:**
- Modify: `src/wf_mcp/workflow_surface/models.py`
- Modify: `src/wf_mcp/workflow_surface/tools.py`
- Modify: `src/wf_mcp/proxy/runtime.py`
- Test: `tests/wf_mcp/workflow_surface/test_drafts.py`
- Test: `tests/wf_mcp/server/test_config.py`
- Test: `tests/wf_mcp/server/test_tools.py`
**Interfaces:**
- Consumes: Task 2 API method and existing `DraftInputBindings` alias.
- Produces: MCP tool `wf.workflow.set_step_input_bindings`.
- [x] **Step 1: Write failing MCP request and handler tests**
Add a request-model test proving path/value union parsing and explicit null.
Add a workflow-surface test that calls the new tool handler with ordered
bindings and asserts the stored canonical list. Add server catalog assertions
for the tool name and that its request schema exposes `bindings` but no
`merge`.
- [x] **Step 2: Run MCP tests and confirm RED**
Run:
```bash
uv run pytest tests/wf_mcp/workflow_surface/test_drafts.py tests/wf_mcp/server/test_config.py tests/wf_mcp/server/test_tools.py -k "step_input_bindings" -q
```
Expected: failures because the request model and tool are absent.
- [x] **Step 3: Add the request model and tool**
Reuse the existing alias:
```python
class SetStepInputBindingsRequest(BaseModel):
"""Replace one step's complete canonical input-binding list."""
workspace_id: WorkspaceId
revision: int = Field(ge=1, description="Expected current workspace revision.")
step_id: NonEmptyString
bindings: DraftInputBindings
```
Register:
```python
@server.tool(
name="wf.workflow.set_step_input_bindings",
title="Set Step Input Bindings",
description=(
"Replace one capability step's complete canonical input-binding list "
"atomically. Supports graph-path and literal bindings; inspect the "
"draft first because replacement is not a merge."
),
)
async def set_step_input_bindings(
request: SetStepInputBindingsRequest,
) -> DraftWorkspaceResult:
return DraftWorkspaceResult.model_validate(
await handlers.set_step_input_bindings(
workspace_id=request.workspace_id,
revision=request.revision,
step_id=request.step_id,
bindings=request.bindings,
)
)
```
Add the tool name to the proxy runtime allowlist next to
`wf.workflow.set_step_input_map`. Keep the compatibility tool registered.
- [x] **Step 4: Run MCP verification**
Run:
```bash
uv run pytest tests/wf_mcp/workflow_surface/test_drafts.py tests/wf_mcp/server/test_config.py tests/wf_mcp/server/test_tools.py -q
uv run ruff check src/wf_mcp/workflow_surface src/wf_mcp/proxy/runtime.py tests/wf_mcp/workflow_surface/test_drafts.py tests/wf_mcp/server/test_config.py tests/wf_mcp/server/test_tools.py
uv run basedpyright --level error src/wf_mcp/workflow_surface src/wf_mcp/proxy/runtime.py
```
Expected: all pass and the generated MCP schema contains the canonical union.
- [x] **Step 5: Commit Task 4**
```bash
git add src/wf_mcp/workflow_surface src/wf_mcp/proxy/runtime.py tests/wf_mcp/workflow_surface/test_drafts.py tests/wf_mcp/server/test_config.py tests/wf_mcp/server/test_tools.py
git commit -m "feat: expose canonical input bindings to mcp"
```
---
### Task 5: Typer Replacement Modes And Remote CLI
**Files:**
- Modify: `src/wf_cli/commands/draft_options.py`
- Modify: `src/wf_cli/commands/draft_add.py`
- Modify: `src/wf_cli/commands/drafts.py`
- Test: `tests/wf_cli/test_app.py`
- Test: `tests/wf_cli/test_remote_target.py`
**Interfaces:**
- Consumes: `WorkflowApiSurface.set_step_input_bindings` and the legacy `set_step_input_map` adapter.
- Produces: canonical `wf draft set-input` replacement modes described by the spec.
- [x] **Step 1: Write failing parser tests**
Add tests for a list-preserving path parser that accepts duplicate sources,
a literal parser that preserves explicit null and JSON strings containing
`=`, and a bindings-file parser that validates the canonical union:
```python
bindings = parse_step_input_binding_flags(
["state.title=request.title", "state.title=audit.title"]
)
assert [str(binding.path) for binding in bindings] == [
"state.title",
"state.title",
]
```
```python
values = parse_step_input_value_flags(
['request.format="markdown"', "request.optional=null"]
)
assert values[0].value == "markdown"
assert values[1].value is None
```
- [x] **Step 2: Write failing command-mode tests**
Cover:
- `--map` plus `--value` calls `set_step_input_bindings` once;
- `--bindings-file` preserves exact order;
- `--clear` sends `bindings=[]`;
- no mode errors;
- file plus flags errors;
- `--merge` plus value/file/clear errors;
- map-only `--merge` still calls `set_step_input_map`;
- map replacement permits repeated source fan-out.
Assert compact `typer.BadParameter` text and that handlers are not called on
invalid combinations.
- [x] **Step 3: Run CLI tests and confirm RED**
Run:
```bash
uv run pytest tests/wf_cli/test_app.py -k "set_input" -q
```
Expected: failures for missing options, parsers, and handler delegation.
- [x] **Step 4: Implement canonical CLI parsers**
Keep `_parse_step_input_map_flags` unchanged for compatibility. Add list-based
helpers using canonical models:
```python
_INPUT_BINDINGS_ADAPTER = TypeAdapter(list[InputBinding])
def validation_error_as_bad_parameter(
exc: ValidationError,
) -> typer.BadParameter:
"""Keep Pydantic failures on Click's concise input-error surface."""
return typer.BadParameter(str(exc))
def parse_step_input_binding_flags(
values: list[str] | None,
) -> list[InputPathBinding]:
bindings: list[InputPathBinding] = []
for item in values or []:
source, separator, target = item.partition("=")
if separator != "=" or not source or not target:
raise typer.BadParameter("--map must use GRAPH_SOURCE=LOCAL_TARGET")
try:
bindings.append(InputPathBinding(path=source, target=target))
except ValidationError as exc:
raise validation_error_as_bad_parameter(exc) from exc
return bindings
def parse_step_input_value_flags(
values: list[str] | None,
) -> list[InputValueBinding]:
bindings: list[InputValueBinding] = []
for item in values or []:
target, separator, raw_value = item.partition("=")
if separator != "=" or not target:
raise typer.BadParameter("--value must use LOCAL_TARGET=JSON")
try:
value = json.loads(raw_value)
bindings.append(InputValueBinding(target=target, value=value))
except json.JSONDecodeError as exc:
raise typer.BadParameter(
f"--value for {target!r} is invalid JSON: {exc.msg}"
) from exc
except ValidationError as exc:
raise validation_error_as_bad_parameter(exc) from exc
return bindings
def parse_step_input_bindings_file(path: Path) -> list[InputBinding]:
try:
return _INPUT_BINDINGS_ADAPTER.validate_python(
parse_json_file(path, option_name="--bindings-file")
)
except ValidationError as exc:
raise validation_error_as_bad_parameter(exc) from exc
```
Delete private `_as_bad_parameter` from `draft_add.py`, import
`validation_error_as_bad_parameter` from `draft_options.py`, and replace its
seven current call sites. This keeps all Pydantic-to-Typer formatting in one
helper rather than duplicating it.
- [x] **Step 5: Implement command mode selection**
Add `--value`, `--bindings-file`, and `--clear` options to `set-input`. Keep
`--merge` but describe it as compatibility-only. Use explicit mode checks:
```python
has_flags = bool(mapping or literal_values)
has_file = bindings_file is not None
selected_modes = sum((has_flags, has_file, clear))
if selected_modes == 0:
raise typer.BadParameter(
"provide --map/--value, --bindings-file, or --clear"
)
if selected_modes > 1:
raise typer.BadParameter(
"--bindings-file and --clear cannot be combined with --map or --value"
)
if merge and (literal_values or has_file or clear):
raise typer.BadParameter(
"--merge is supported only for compatibility map-only edits"
)
```
For `merge=True`, call the unchanged map handler. Otherwise build bindings as
file order, `[]`, or path flags followed by literal flags, and call
`set_step_input_bindings`.
- [x] **Step 6: Add a real remote CLI round trip**
Extend `tests/wf_cli/test_remote_target.py` to start the local JSON-RPC app,
create/inspect a draft, export the current input list to a test JSON file,
replace it with path fan-out plus a literal, and inspect the stored result.
Assert the recorded method is
`workflow.draft_workspaces.set_step_input_bindings` and the revision advances
once.
- [x] **Step 7: Run CLI verification**
Run:
```bash
uv run pytest tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py -k "set_input or input_bindings" -q
uv run ruff check src/wf_cli/commands/draft_options.py src/wf_cli/commands/draft_add.py src/wf_cli/commands/drafts.py tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py
uv run basedpyright --level error src/wf_cli/commands/draft_options.py src/wf_cli/commands/draft_add.py src/wf_cli/commands/drafts.py
```
Expected: all pass. Run `uv run wf draft set-input --help` and verify the
output distinguishes replacement modes from compatibility `--merge`.
- [x] **Step 8: Commit Task 5**
```bash
git add src/wf_cli/commands/draft_options.py src/wf_cli/commands/draft_add.py src/wf_cli/commands/drafts.py tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py
git commit -m "feat: replace draft input bindings from cli"
```
---
### Task 6: Documentation, Issue State, Review, And Final Verification
**Files:**
- Modify: `docs/wf_cli.md`
- Modify: `skills/wf-cli/SKILL.md`
- Modify: `skills/wf-workflow/references/draft-workspaces.md`
- Modify: `ISSUES.md`
- Modify: `docs/current_roadmap.md`
- Modify then move: `docs/superpowers/plans/2026-07-22-atomic-step-input-bindings.md`
**Interfaces:**
- Consumes: all completed behavior from Tasks 1-5.
- Produces: current user/agent guidance and an archived completed plan.
- [x] **Step 1: Update user and agent documentation**
Document these exact workflows:
```bash
wf draft inspect WS --include-draft |
jq '.draft.steps.publish.input' > bindings.json
wf draft set-input WS --revision 4 --step publish \
--map state.report.title=request.title \
--map state.report.markdown=request.body \
--value request.format='"markdown"'
wf draft set-input WS --revision 5 --step publish \
--bindings-file bindings.json
wf draft set-input WS --revision 6 --step publish --clear
```
State plainly that replacement is default, `--bindings-file` is canonical,
repeated source paths are allowed, and `--merge` is map-only compatibility.
- [x] **Step 2: Update issue and roadmap state narrowly**
In `ISSUES.md`, check:
- atomic structured node input assembly;
- literal node-input bindings.
Keep fan-out map loss open and clarify that the canonical replacement avoids
loss while compatibility map readers/writers can still collapse it. Leave all
output, step-update, and TypeScript issues open.
Add one completed roadmap entry linking to the historical plan path.
- [x] **Step 3: Run the complete focused verification matrix**
Run:
```bash
uv run pytest \
tests/wf_api/test_schema_projection.py \
tests/wf_api/test_drafts_service.py \
tests/wf_transport_rpc_http/test_app.py \
tests/wf_transport_rpc_http/test_client.py \
tests/wf_mcp/workflow_surface/test_drafts.py \
tests/wf_mcp/server/test_config.py \
tests/wf_mcp/server/test_tools.py \
tests/wf_cli/test_app.py \
tests/wf_cli/test_remote_target.py \
tests/core/test_nested_mappings.py \
tests/core/test_canonical_node_bindings.py \
-q
uv run ruff check
uv run ruff format --check
uv run basedpyright --level error
```
Expected: all tests pass; Ruff and basedpyright report no errors. Existing
third-party deprecation warnings may remain but must be reported.
- [x] **Step 4: Run independent review and fix valid findings**
Use the repository code-review workflow against the design spec and this
plan. Require reviewers to check:
- stale-revision precedence;
- no mutation on semantic failure;
- exact no-op behavior;
- fan-out survival through CLI and transport;
- explicit null preservation;
- whole-payload projection;
- absence of duplicate binding models;
- legacy map merge compatibility.
Apply valid fixes and rerun the affected focused tests plus Ruff and
basedpyright.
- [x] **Step 5: Complete and archive the plan**
Check every completed task box, then move:
```text
docs/superpowers/plans/2026-07-22-atomic-step-input-bindings.md
-> docs/historical/superpowers/plans/2026-07-22-atomic-step-input-bindings.md
```
Confirm all live links use the historical path.
- [x] **Step 6: Commit Task 6**
```bash
git add docs/wf_cli.md skills/wf-cli/SKILL.md \
skills/wf-workflow/references/draft-workspaces.md ISSUES.md \
docs/current_roadmap.md docs/superpowers/plans \
docs/historical/superpowers/plans
git commit -m "docs: complete atomic step input bindings"
```
- [x] **Step 7: Confirm final repository state**
Run:
```bash
git status --short
git log -6 --oneline
git diff HEAD^ --check
```
Expected: clean worktree, six task commits (plus this plan commit), and no
whitespace errors.