Files
lda-wf/docs/historical/superpowers/plans/2026-07-23-atomic-workflow-output-bindings.md
T

1260 lines
42 KiB
Markdown

# Atomic Workflow Output 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 (`- [x]`) syntax for tracking.
**Goal:** Add one revision-checked canonical workflow-output binding replacement operation across Python, JSON-RPC, MCP, and CLI, with nested schema projection, literal validation, and unchanged empty-list fallback semantics.
**Architecture:** Keep semantic authoring in `WorkflowDraftAuthoringApi`, where revision precedence and atomic patches already live. Reuse the existing `InputBinding` union and deepen `wf_api.schema_projection` only enough to give literal validation neutral diagnostic labels. RPC, MCP, and CLI pass the ordered canonical list without lowering through the compatibility map.
**Tech Stack:** Python 3.14, Pydantic 2, JSON Schema Draft 2020-12, Typer, FastAPI JSON-RPC, FastMCP, pytest, Ruff, basedpyright.
## Global Constraints
- Do not change persisted `WorkflowDraft.output`, core `Workflow.output`, runtime output projection, or end-node behavior.
- Empty canonical bindings restore the existing same-name state fallback; they do not mean an always-empty public output.
- Reuse `InputPathBinding | InputValueBinding`; do not add another binding model.
- Do not infer JSON Schema from literal values or `context.*` paths.
- Input/state path projection and literal validation must stay in `wf_api.schema_projection` helpers.
- Preserve binding order exactly and reject equal or ancestor/descendant output targets.
- Check revision before semantic path/schema inspection; retain the mutation-time revision guard.
- Keep `set_workflow_output_map(...)` and `--merge --map` as compatibility-only behavior.
- Python baseline is 3.14. Use current syntax and add comments around non-obvious reference/root behavior.
- Scope tests to the files named by each task; run the complete plan matrix only in Task 5.
---
### Task 1: Canonical Python Authoring And Runtime Proof
**Files:**
- Modify: `src/wf_api/schema_projection.py`
- Modify: `src/wf_api/draft_authoring.py`
- Modify: `tests/wf_api/test_schema_projection.py`
- Modify: `tests/wf_api/test_drafts_service.py`
**Interfaces:**
- Consumes: `InputBinding`, `InputPathBinding`, `InputValueBinding`, `GraphSourcePath`, `LocalPath`, `schema_fragment_at_path(...)`, `schema_path_exists(...)`, `project_schema_path_to_schema_path(...)`, and `validate_json_value_at_schema_path(...)`.
- Produces: `WorkflowDraftAuthoringApi.set_workflow_output_bindings(*, workspace_id: str, revision: int, bindings: Sequence[InputBinding]) -> dict[str, Any]`.
- Produces: `validate_json_value_at_schema_path(..., schema_label: str = "capability input schema") -> None`, preserving existing callers through the default.
- [x] **Step 1: Write failing neutral-label and referenced-target schema tests**
Add tests in `tests/wf_api/test_schema_projection.py` that pin the shared helper contract:
```python
def test_validate_json_value_uses_caller_schema_label() -> None:
with pytest.raises(
ValueError,
match="workflow output schema",
):
validate_json_value_at_schema_path(
schema={
"type": "object",
"properties": {"format": {"type": "string"}},
},
parts=("missing",),
value="markdown",
label="bindings[0].value",
schema_label="workflow output schema",
)
def test_project_schema_path_updates_output_target_through_local_ref() -> None:
projected = project_schema_path_to_schema_path(
target_schema={
"type": "object",
"properties": {"report": {"$ref": "#/$defs/Report"}},
"$defs": {
"Report": {"type": "object", "properties": {}}
},
},
source_schema={
"type": "object",
"properties": {"title": {"type": "string"}},
},
source_parts=("title",),
target_parts=("report", "title"),
allow_existing_equivalent=True,
)
assert projected["properties"]["report"] == {"$ref": "#/$defs/Report"}
assert projected["$defs"]["Report"]["properties"]["title"] == {
"type": "string"
}
```
The second test protects the target-side `$ref` behavior introduced by the step-output slice while this operation begins relying on it directly.
- [x] **Step 2: Run the schema tests and verify the neutral label test fails**
Run:
```bash
uv run pytest tests/wf_api/test_schema_projection.py -q
```
Expected: the existing referenced-target test passes and the new
`schema_label` call fails because the helper does not yet accept that keyword.
- [x] **Step 3: Generalize the literal-validation diagnostic label**
Change `validate_json_value_at_schema_path(...)` in
`src/wf_api/schema_projection.py` without adding a second validator:
```python
def validate_json_value_at_schema_path(
*,
schema: JsonObject,
parts: Sequence[str],
value: object,
label: str,
schema_label: str = "capability input schema",
) -> None:
"""Validate one JSON-compatible literal against a selected schema path."""
fragment = schema_fragment_at_path(
schema,
parts,
label=schema_label,
)
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
```
Run the schema test file again. Expected: PASS.
- [x] **Step 4: Write failing canonical authoring tests**
In `tests/wf_api/test_drafts_service.py`, add a helper draft whose input, state,
and output schemas contain nested fields and whose output list initially is
empty. Add tests with these exact behavioral assertions:
```python
@pytest.mark.asyncio
async def test_set_workflow_output_bindings_projects_nested_paths_and_literals(
tmp_path: Path,
) -> None:
draft_api, service, authoring = _draft_api(
FileWorkflowArtifactStore(tmp_path / "workflow_outputs"),
register_echo=True,
)
draft = _echo_draft()
draft["state_schema"] = {
"type": "object",
"properties": {
"report": {
"type": "object",
"properties": {"title": {"type": "string"}},
}
},
}
draft["output_schema"] = {
"type": "object",
"properties": {"format": {"type": "string"}},
}
draft["output"] = []
await draft_api.create_draft_workspace(workspace_id="report", draft=draft)
result = await authoring.set_workflow_output_bindings(
workspace_id="report",
revision=1,
bindings=[
InputPathBinding(
path=GraphSourcePath.state("report", "title"),
target=LocalPath.of("report", "title"),
),
InputPathBinding(
path=GraphSourcePath.state("report", "title"),
target=LocalPath.of("audit", "title"),
),
InputValueBinding(
target=LocalPath.of("format"),
value="markdown",
),
],
)
inspected = await draft_api.get_draft_workspace(
workspace_id="report",
include_draft=True,
)
assert result["revision"] == 2
assert inspected["draft"]["output"] == [
{"path": "state.report.title", "target": "report.title"},
{"path": "state.report.title", "target": "audit.title"},
{"value": "markdown", "target": "format"},
]
assert inspected["draft"]["output_schema"]["properties"]["report"][
"properties"
]["title"] == {"type": "string"}
assert inspected["draft"]["output_schema"]["properties"]["audit"][
"properties"
]["title"] == {"type": "string"}
```
Add focused tests for the remaining contract, using field-level assertions:
- exact replacement and stable ordering;
- exact no-op with unchanged revision;
- `[]` clearing `/output` without deleting `output_schema`;
- nested `input.*` projection;
- whole `input` or `state` projection into one nested target;
- declared `context.*` target accepted without schema inference;
- missing context target rejected;
- literal string, object, array, and explicit `null` validation;
- literal target missing from `output_schema` rejected;
- root path target accepted only for an exactly equal complete schema;
- root literal requires a mapping and validates the complete output schema;
- root target overlaps any other target;
- duplicate and ancestor/descendant targets rejected;
- missing source reported before target overlap;
- incompatible existing target rejected;
- stale revision wins over every semantic error and leaves the draft unchanged.
Use one parameterized error test shaped like:
```python
@pytest.mark.parametrize(
("bindings", "message"),
[
(
[
InputPathBinding(
path=GraphSourcePath.state("missing"),
target=LocalPath.of("missing"),
)
],
r"bindings\[0\]\.path 'state\.missing' is not declared",
),
(
[
InputValueBinding(
target=LocalPath.of("missing"),
value="x",
)
],
r"bindings\[0\]\.target 'missing' is not declared",
),
(
[
InputPathBinding(
path=GraphSourcePath.state("report"),
target=LocalPath.of("report"),
),
InputValueBinding(
target=LocalPath.of("report", "format"),
value="markdown",
),
],
r"bindings\[0\]\.target 'report' overlaps bindings\[1\]",
),
],
)
async def test_set_workflow_output_bindings_rejects_without_mutation(
tmp_path: Path,
bindings: list[InputBinding],
message: str,
) -> None:
# Create the draft, snapshot include_draft=True, assert the error, then
# assert the complete inspected workspace still equals the snapshot.
```
- [x] **Step 5: Run focused authoring tests and verify they fail**
Run:
```bash
uv run pytest tests/wf_api/test_drafts_service.py -q -k "workflow_output_bindings"
```
Expected: FAIL because `WorkflowDraftAuthoringApi` has no canonical workflow
output method.
- [x] **Step 6: Implement the canonical authoring operation**
In `src/wf_api/draft_authoring.py`, import `Mapping`, `InputBinding`,
`InputPathBinding`, `InputValueBinding`, `GraphSourcePath`, `LocalPath`, and the
shared schema helpers. Add focused private helpers rather than embedding all
path logic in the method:
```python
def _workflow_source_schema(
draft: Mapping[str, Any],
path: GraphSourcePath,
) -> dict[str, Any] | None:
"""Return the declared graph-source schema, or None for context paths."""
if path.root == "input":
key = "input_schema"
elif path.root == "state":
key = "state_schema"
else:
return None
value = draft.get(key, {})
if not isinstance(value, dict):
raise ValueError(f"draft {key} must be an object")
return value
def _overlapping_workflow_output_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")
```
Implement the method on `WorkflowDraftAuthoringApi` with this ordering:
```python
async def set_workflow_output_bindings(
self,
*,
workspace_id: str,
revision: int,
bindings: Sequence[InputBinding],
) -> dict[str, Any]:
"""Replace canonical workflow output bindings atomically."""
checked = self._workspace_if_revision_matches(
workspace_id=workspace_id,
revision=revision,
)
if isinstance(checked, dict):
return checked
workspace = checked
output_schema = _draft_schema(workspace.draft, "output_schema")
source_schemas: dict[int, dict[str, Any]] = {}
source_fragments: dict[int, dict[str, Any]] = {}
for index, binding in enumerate(bindings):
if not isinstance(binding, InputPathBinding):
continue
source_schema = _workflow_source_schema(workspace.draft, binding.path)
if source_schema is None:
continue
try:
source_schemas[index] = source_schema
source_fragments[index] = schema_fragment_at_path(
source_schema,
binding.path.parts,
label=f"workflow {binding.path.root} schema",
)
except ValueError as exc:
raise ValueError(
f"bindings[{index}].path {str(binding.path)!r} "
f"is not declared: {exc}"
) from exc
if has_overlapping_paths(binding.target for binding in bindings):
raise _overlapping_workflow_output_targets_error(bindings)
projected = output_schema
for index, binding in enumerate(bindings):
target_parts = binding.target.parts
if isinstance(binding, InputPathBinding):
source_schema = source_schemas.get(index)
if source_schema is None:
if not schema_path_exists(projected, target_parts):
raise ValueError(
f"bindings[{index}].path {str(binding.path)!r} "
"requires a declared output target"
)
continue
if not target_parts:
if projected != source_fragments[index]:
raise ValueError(
f"bindings[{index}].target '.' already has an "
"incompatible schema"
)
continue
projected = project_schema_path_to_schema_path(
target_schema=projected,
source_schema=source_schema,
source_parts=binding.path.parts,
target_parts=target_parts,
allow_existing_equivalent=True,
)
continue
if not isinstance(binding, InputValueBinding):
raise TypeError(f"unsupported workflow output binding {binding!r}")
if not target_parts and not isinstance(binding.value, Mapping):
raise ValueError(
f"bindings[{index}].value for root target must be an object"
)
if not schema_path_exists(projected, target_parts):
raise ValueError(
f"bindings[{index}].target {str(binding.target)!r} is not declared"
)
validate_json_value_at_schema_path(
schema=projected,
parts=target_parts,
value=binding.value,
label=f"bindings[{index}].value",
schema_label="workflow output schema",
)
payload = [binding.model_dump(mode="json") for binding in bindings]
if workspace.draft.get("output", []) == payload and projected == output_schema:
return summarize_draft_workspace(workspace)
patch: list[dict[str, Any]] = []
if projected != output_schema:
patch.append({"op": "replace", "path": "/output_schema", "value": projected})
patch.append({"op": "replace", "path": "/output", "value": payload})
return await self.drafts.patch_draft_workspace(
workspace_id=workspace_id,
revision=revision,
patch=patch,
)
```
Do not copy this mechanically if an existing helper provides the same behavior;
reuse it. Preserve the specified validation order and wrap projection failures
with `bindings[index]` context.
- [x] **Step 7: Add compile-and-execute coverage**
Add a test that creates a valid draft, invokes the new authoring method, compiles
with `compile_draft_workspace`, runs through `WfMcpService.run_workflow_from_plan`,
and asserts:
```python
assert run.error is None
assert run.output == {
"report": {"title": "Thesis"},
"audit": {"title": "Thesis"},
"format": "markdown",
}
```
Add a second execution assertion for a cleared explicit list: declare a
top-level output field matching state, clear bindings, compile/run, and prove the
existing implicit fallback still returns that state field.
- [x] **Step 8: Run Task 1 verification**
Run:
```bash
uv run pytest tests/wf_api/test_schema_projection.py tests/wf_api/test_drafts_service.py -q
uv run ruff check src/wf_api/schema_projection.py src/wf_api/draft_authoring.py tests/wf_api/test_schema_projection.py tests/wf_api/test_drafts_service.py
uv run basedpyright --level error src/wf_api/schema_projection.py src/wf_api/draft_authoring.py
```
Expected: all pass. If the installed basedpyright does not accept file paths,
run the repository-wide `uv run basedpyright --level error` instead.
- [x] **Step 9: Review and commit Task 1**
Review the Task 1 diff against the spec, especially root-target equality,
context behavior, stale-revision precedence, and no mutation on errors. Then:
```bash
git add src/wf_api/schema_projection.py src/wf_api/draft_authoring.py tests/wf_api/test_schema_projection.py tests/wf_api/test_drafts_service.py
git commit -m "feat: replace canonical workflow output bindings"
```
---
### Task 2: Public API And JSON-RPC Transport
**Files:**
- Modify: `src/wf_api/surface.py`
- Modify: `src/wf_api/service.py`
- 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`
- Modify: `src/wf_transport_rpc_http/__init__.py`
- Modify: `tests/wf_transport_rpc_http/test_app.py`
- Modify: `tests/wf_transport_rpc_http/test_client.py`
**Interfaces:**
- Consumes: Task 1's `WorkflowDraftAuthoringApi.set_workflow_output_bindings(...)`.
- Produces: `WorkflowApiSurface.set_workflow_output_bindings(...)`, `WorkflowApi.set_workflow_output_bindings(...)`, `SetWorkflowOutputBindingsParams`, JSON-RPC method `workflow.draft_workspaces.set_workflow_output_bindings`, and `RpcWorkflowApiClient.set_workflow_output_bindings(...)`.
- [x] **Step 1: Write failing RPC model and endpoint tests**
In `tests/wf_transport_rpc_http/test_app.py`, add an end-to-end request using a
real local server:
```python
async def test_rpc_set_workflow_output_bindings_preserves_union_and_order(
tmp_path,
) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
app = create_rpc_app(server)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://test",
) as client:
created = await _rpc(
client,
"workflow.draft_workspaces.create_from_capability",
{
"workspace_id": "output_ws",
"capability_name": "wf.std.constant",
"name": "output_test",
},
)
result = await _rpc(
client,
"workflow.draft_workspaces.set_workflow_output_bindings",
{
"workspace_id": "output_ws",
"revision": created["result"]["revision"],
"bindings": [
{"path": "state.value", "target": "value"},
{"path": "state.value", "target": "audit.value"},
],
},
)
assert result["result"]["revision"] == 2
```
Use a fixture with a declared literal target for a second request and assert the
stored list preserves a path binding followed by a value binding. Add malformed
requests proving `path` plus `value`, missing `target`, extra fields, and invalid
roots fail with JSON-RPC `-32602` before semantic logic.
- [x] **Step 2: Write the failing remote client test**
In `tests/wf_transport_rpc_http/test_client.py`, add:
```python
async def test_rpc_client_set_workflow_output_bindings_preserves_union_order(
rpc_client,
calls,
) -> None:
bindings: list[InputBinding] = [
InputPathBinding(
path=GraphSourcePath.state("value"),
target=LocalPath.of("value"),
),
InputValueBinding(
target=LocalPath.of("format"),
value="markdown",
),
]
await rpc_client.set_workflow_output_bindings(
workspace_id="ws",
revision=3,
bindings=bindings,
)
assert calls[-1]["method"] == (
"workflow.draft_workspaces.set_workflow_output_bindings"
)
assert calls[-1]["params"]["bindings"] == [
{"path": "state.value", "target": "value"},
{"value": "markdown", "target": "format"},
]
```
Adapt the fixture names to the existing client test harness; keep the assertions
field-level.
- [x] **Step 3: Run RPC tests and verify they fail**
Run:
```bash
uv run pytest tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py -q -k "workflow_output_bindings"
```
Expected: FAIL because the params model, endpoint, and client method are absent.
- [x] **Step 4: Add the public surface and local service delegation**
Add this method to `WorkflowApiSurface` in `src/wf_api/surface.py`:
```python
async def set_workflow_output_bindings(
self,
*,
workspace_id: str,
revision: int,
bindings: Sequence[InputBinding],
) -> dict[str, Any]: ...
```
Add the concrete method to `WorkflowApi` in `src/wf_api/service.py`:
```python
async def set_workflow_output_bindings(
self,
*,
workspace_id: str,
revision: int,
bindings: Sequence[InputBinding],
) -> dict[str, Any]:
return await self.draft_authoring.set_workflow_output_bindings(
workspace_id=workspace_id,
revision=revision,
bindings=bindings,
)
```
Keep `set_workflow_output_map(...)` unchanged and adjacent as the compatibility
surface.
- [x] **Step 5: Add the typed RPC request, endpoint, and client**
In `src/wf_transport_rpc_http/models.py`:
```python
class SetWorkflowOutputBindingsParams(RpcParamsModel):
workspace_id: str = Field(min_length=1)
revision: int = Field(ge=1)
bindings: list[InputBinding]
```
Register the endpoint in `src/wf_transport_rpc_http/methods/drafts.py` adjacent
to `set_workflow_output_map`:
```python
@entrypoint.method(
name="workflow.draft_workspaces.set_workflow_output_bindings",
errors=[WorkflowRpcError],
)
async def workflow_draft_workspaces_set_workflow_output_bindings(
params: SetWorkflowOutputBindingsParams = RpcParams(),
) -> dict[str, Any]:
try:
return await server.api.set_workflow_output_bindings(
workspace_id=params.workspace_id,
revision=params.revision,
bindings=params.bindings,
)
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
raise_workflow_rpc_error(exc)
```
Add the client method in `src/wf_transport_rpc_http/client/drafts.py`:
```python
async def set_workflow_output_bindings(
self: RpcCaller,
*,
workspace_id: str,
revision: int,
bindings: Sequence[InputBinding],
) -> dict[str, Any]:
return await self._call(
"workflow.draft_workspaces.set_workflow_output_bindings",
{
"workspace_id": workspace_id,
"revision": revision,
"bindings": [
binding.model_dump(mode="json") for binding in bindings
],
},
)
```
Export `SetWorkflowOutputBindingsParams` from
`src/wf_transport_rpc_http/__init__.py` in both import and `__all__` lists.
- [x] **Step 6: Run Task 2 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 basedpyright --level error
```
Expected: all pass and every `WorkflowApiSurface` implementation conforms.
- [x] **Step 7: Review and commit Task 2**
Review request validation, canonical order, and local/remote interface parity.
Then:
```bash
git add src/wf_api/surface.py src/wf_api/service.py src/wf_transport_rpc_http/models.py src/wf_transport_rpc_http/methods/drafts.py src/wf_transport_rpc_http/client/drafts.py src/wf_transport_rpc_http/__init__.py tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py
git commit -m "feat: expose workflow output bindings over rpc"
```
---
### Task 3: MCP Workflow Surface
**Files:**
- Modify: `src/wf_mcp/workflow_surface/models.py`
- Modify: `src/wf_mcp/workflow_surface/tools.py`
- Modify: `src/wf_mcp/proxy/runtime.py`
- Modify: `tests/wf_mcp/workflow_surface/test_drafts.py`
- Modify: `tests/wf_mcp/server/test_tools.py`
- Modify: `tests/wf_mcp/server/test_config.py`
**Interfaces:**
- Consumes: Task 2's public `set_workflow_output_bindings(...)` method and the existing `DraftInputBindings` alias.
- Produces: `SetWorkflowOutputBindingsRequest` and MCP tool `wf.workflow.set_workflow_output_bindings`.
- [x] **Step 1: Write failing MCP request-model tests**
In `tests/wf_mcp/workflow_surface/test_drafts.py`, add:
```python
def test_set_workflow_output_bindings_request_preserves_union_order() -> None:
request = SetWorkflowOutputBindingsRequest.model_validate(
{
"workspace_id": "ws",
"revision": 2,
"bindings": [
{"path": "state.title", "target": "report.title"},
{"value": "markdown", "target": "format"},
],
}
)
assert [binding.model_dump(mode="json") for binding in request.bindings] == [
{"path": "state.title", "target": "report.title"},
{"value": "markdown", "target": "format"},
]
```
Add a malformed-record test with both `path` and `value`, and assert Pydantic
rejects the extra union field.
- [x] **Step 2: Write failing tool-registration and invocation tests**
Extend the fake handler in `tests/wf_mcp/server/test_tools.py` with a call log for
`set_workflow_output_bindings`. Assert the tool inventory includes the new name,
then invoke the actual FastMCP tool through the existing test helper:
```python
result = await call_tool(
server,
"wf.workflow.set_workflow_output_bindings",
{
"request": {
"workspace_id": "ws",
"revision": 4,
"bindings": [
{"path": "state.title", "target": "report.title"},
{"value": "markdown", "target": "format"},
],
}
},
)
assert result.isError is False
assert handler.calls[-1]["bindings"][0].path == GraphSourcePath.state("title")
assert handler.calls[-1]["bindings"][1].value == "markdown"
```
Update config/discovery expectations so the public tool count/name inventory is
explicit rather than only asserting a count.
- [x] **Step 3: Run MCP tests and verify they fail**
Run:
```bash
uv run pytest tests/wf_mcp/workflow_surface/test_drafts.py tests/wf_mcp/server/test_tools.py tests/wf_mcp/server/test_config.py -q -k "workflow_output_bindings or tools or config"
```
Expected: FAIL because the request and tool do not exist.
- [x] **Step 4: Add the MCP request and tool**
In `src/wf_mcp/workflow_surface/models.py`:
```python
class SetWorkflowOutputBindingsRequest(BaseModel):
"""Replace the complete canonical workflow-output binding list."""
workspace_id: WorkspaceId
revision: int = Field(ge=1, description="Expected current workspace revision.")
bindings: DraftInputBindings
```
In `src/wf_mcp/workflow_surface/tools.py`, register adjacent to the map tool:
```python
@server.tool(
name="wf.workflow.set_workflow_output_bindings",
title="Set Workflow Output Bindings",
description=(
"Replace the complete ordered workflow-output binding list with "
"canonical path/value records."
),
)
async def set_workflow_output_bindings(
request: SetWorkflowOutputBindingsRequest,
) -> DraftWorkspaceResult:
return DraftWorkspaceResult.model_validate(
await handlers.set_workflow_output_bindings(
workspace_id=request.workspace_id,
revision=request.revision,
bindings=request.bindings,
)
)
```
Add the new name to the explicit workflow proxy allow-list in
`src/wf_mcp/proxy/runtime.py`. Preserve the existing map tool.
- [x] **Step 5: Run Task 3 verification**
Run:
```bash
uv run pytest tests/wf_mcp/workflow_surface/test_drafts.py tests/wf_mcp/server/test_tools.py tests/wf_mcp/server/test_config.py -q
uv run basedpyright --level error
```
Expected: all pass.
- [x] **Step 6: Review and commit Task 3**
Review actual tool invocation, discovery visibility, request validation, and
ordered union preservation. Then:
```bash
git add src/wf_mcp/workflow_surface/models.py src/wf_mcp/workflow_surface/tools.py src/wf_mcp/proxy/runtime.py tests/wf_mcp/workflow_surface/test_drafts.py tests/wf_mcp/server/test_tools.py tests/wf_mcp/server/test_config.py
git commit -m "feat: expose workflow output bindings to mcp"
```
---
### Task 4: Canonical CLI Replacement
**Files:**
- Modify: `src/wf_cli/commands/draft_options.py`
- Modify: `src/wf_cli/commands/drafts.py`
- Modify: `tests/wf_cli/test_app.py`
- Modify: `tests/wf_cli/test_remote_target.py`
**Interfaces:**
- Consumes: Task 2's local/remote `set_workflow_output_bindings(...)` method.
- Produces: canonical `wf draft set-workflow-output` modes `--map`, `--value`, `--bindings-file`, and `--clear`; preserves compatibility-only `--merge --map`.
- [x] **Step 1: Write failing parser tests**
In `tests/wf_cli/test_app.py`, add parser tests proving:
```python
def test_parse_workflow_output_flags_preserves_path_then_literal_order() -> None:
bindings = [
*parse_workflow_output_binding_flags(
[
"state.title=report.title",
"state.title=audit.title",
]
),
*parse_workflow_output_value_flags(
['format="markdown"', "optional=null"]
),
]
assert [binding.model_dump(mode="json") for binding in bindings] == [
{"path": "state.title", "target": "report.title"},
{"path": "state.title", "target": "audit.title"},
{"value": "markdown", "target": "format"},
{"value": None, "target": "optional"},
]
```
Add bindings-file tests for a mixed canonical array and malformed unions. Pin
compact `typer.BadParameter` messages for invalid graph roots, `local.`-prefixed
targets, invalid JSON, and non-array files.
- [x] **Step 2: Extract shared input-shaped CLI parsers**
In `src/wf_cli/commands/draft_options.py`, avoid duplicating the step-input
parser. Extract private helpers parameterized only by audience wording:
```python
def _parse_input_path_binding_flags(
values: list[str] | None,
*,
target_label: str,
) -> list[InputPathBinding]:
# Parse GRAPH_SOURCE=LOCAL_TARGET in caller order, reject local.-prefixed
# targets, and construct typed bindings.
def _parse_input_value_binding_flags(
values: list[str] | None,
*,
target_label: str,
) -> list[InputValueBinding]:
# Parse LOCAL_TARGET=JSON, preserving explicit null and embedded '='.
```
Keep existing public step-input parser names as wrappers. Add workflow-output
wrappers with accurate messages:
```python
def parse_workflow_output_binding_flags(
values: list[str] | None,
) -> list[InputPathBinding]:
return _parse_input_path_binding_flags(
values,
target_label="workflow-output",
)
def parse_workflow_output_value_flags(
values: list[str] | None,
) -> list[InputValueBinding]:
return _parse_input_value_binding_flags(
values,
target_label="workflow-output",
)
def parse_workflow_output_bindings_file(path: Path) -> list[InputBinding]:
"""Read an ordered canonical workflow-output binding list."""
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
```
Do not change accepted step-input syntax or its existing error assertions.
- [x] **Step 3: Write failing command tests**
Add local CLI tests that use a fake handler with separate canonical and map call
logs. Cover:
```python
result = runner.invoke(
app,
[
"draft",
"set-workflow-output",
"report_ws",
"--revision",
"4",
"--map",
"state.title=report.title",
"--map",
"state.title=audit.title",
"--value",
'format="markdown"',
],
)
assert result.exit_code == 0, result.output
assert [binding.model_dump(mode="json") for binding in canonical_calls[0][
"bindings"
]] == [
{"path": "state.title", "target": "report.title"},
{"path": "state.title", "target": "audit.title"},
{"value": "markdown", "target": "format"},
]
assert map_calls == []
```
Add tests for:
- `--bindings-file` exact order;
- `--clear` sending `bindings=[]`;
- no selected mode failing before `load_cli_context`;
- `--bindings-file` and `--clear` mutually exclusive with convenience flags;
- `--merge --value`, `--merge --bindings-file`, and `--merge --clear` rejected;
- `--merge --map` invoking only `set_workflow_output_map(..., merge=True)`;
- help text naming canonical replacement, literals, fallback, export, and lossy
compatibility behavior.
In `tests/wf_cli/test_remote_target.py`, add a real JSON-RPC-target test and
assert the captured method is
`workflow.draft_workspaces.set_workflow_output_bindings` with the exact ordered
mixed binding list. Keep a remote compatibility test for `--merge --map`.
- [x] **Step 4: Run CLI tests and verify they fail**
Run:
```bash
uv run pytest tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py -q -k "workflow_output"
```
Expected: parser tests or command tests fail because canonical modes are absent.
- [x] **Step 5: Replace the command's default path while retaining compatibility**
Update imports and the `set_workflow_output` Typer command in
`src/wf_cli/commands/drafts.py`. Parse modes before loading context:
```python
has_maps = bool(mapping)
has_values = bool(literal_values)
has_file = bindings_file is not None
has_convenience = has_maps or has_values
if not has_convenience and not has_file and not clear:
raise typer.BadParameter(
"provide --map, --value, --bindings-file, or --clear"
)
if has_file and (has_convenience or clear):
raise typer.BadParameter(
"--bindings-file is mutually exclusive with --map, --value, and --clear"
)
if clear and has_convenience:
raise typer.BadParameter(
"--clear is mutually exclusive with --map and --value"
)
if merge and (has_values or has_file or clear):
raise typer.BadParameter(
"--merge is supported only for compatibility map-only edits"
)
if merge:
output_map = _parse_map_flags(mapping)
bindings = None
else:
output_map = None
bindings = (
parse_workflow_output_bindings_file(bindings_file)
if bindings_file is not None
else []
if clear
else [
*parse_workflow_output_binding_flags(mapping),
*parse_workflow_output_value_flags(literal_values),
]
)
context = load_cli_context(ctx)
operation = (
context.handlers.set_workflow_output_map(
workspace_id=workspace_id,
revision=revision,
output_map=output_map,
merge=True,
)
if merge
else context.handlers.set_workflow_output_bindings(
workspace_id=workspace_id,
revision=revision,
bindings=bindings,
)
)
emit_json(run_cli_operation(context, operation))
```
Use assertions or explicit branches to satisfy basedpyright narrowing without
`cast(Any, ...)`. Rewrite the docstring/help so `--clear` explicitly says it
restores implicit same-name state fallback rather than promising empty output.
- [x] **Step 6: Run Task 4 verification**
Run:
```bash
uv run pytest tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py -q
uv run basedpyright --level error
```
Expected: all pass.
- [x] **Step 7: Review and commit Task 4**
Review mode exclusivity, parsing-before-context, flag ordering, local/remote
parity, and compatibility isolation. Then:
```bash
git add src/wf_cli/commands/draft_options.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 workflow output bindings from cli"
```
---
### Task 5: Documentation, Issue State, Review, And Final Verification
**Files:**
- Modify: `ISSUES.md`
- Modify: `docs/wf_cli.md`
- Modify: `docs/workflow_drafts.md`
- Modify: `docs/workflow_capabilities.md`
- Modify: `docs/wf_mcp_operator_manual.md`
- Modify: `skills/wf-cli/SKILL.md`
- Modify: `skills/wf-workflow/references/draft-workspaces.md`
- Modify: `skills/wf-workflow/references/workflow-lifecycle.md`
- Modify: `docs/current_roadmap.md`
- Moved: `docs/superpowers/plans/2026-07-23-atomic-workflow-output-bindings.md` to `docs/historical/superpowers/plans/2026-07-23-atomic-workflow-output-bindings.md`
**Interfaces:**
- Consumes: verified behavior from Tasks 1-4.
- Produces: current user/agent documentation, accurate issue state, completed roadmap entry, archived checked plan, and final review evidence.
- [x] **Step 1: Update issue state without closing compatibility-map loss**
In `ISSUES.md`:
- check the focused workflow-output literal binding issue;
- check the nested workflow-output schema projection issue;
- leave compatibility step input/output map loss open;
- preserve focused step update and TypeScript parity as open;
- state that canonical workflow-output replacement preserves path/value union
order while `set_workflow_output_map` remains compatibility-only.
- [x] **Step 2: Update live CLI and workflow docs**
Update `docs/wf_cli.md` and `docs/workflow_drafts.md` with all canonical modes:
```bash
wf draft set-workflow-output WS --revision 4 \
--map state.report.title=report.title \
--value format='"markdown"'
wf draft inspect WS --include-draft |
jq '.draft.output' > output-bindings.json
wf draft set-workflow-output WS --revision 5 \
--bindings-file output-bindings.json
wf draft set-workflow-output WS --revision 6 --clear
```
Explain:
- path/value bindings are canonical ordered records;
- nested input/state sources project missing nested output schemas;
- literals and context paths require declared output targets;
- `--clear` restores implicit same-name state fallback;
- `--merge --map` is compatibility-only and potentially lossy.
Update operation inventories in `docs/workflow_capabilities.md` and
`docs/wf_mcp_operator_manual.md` with both
`workflow.draft_workspaces.set_workflow_output_bindings` and
`wf.workflow.set_workflow_output_bindings`.
- [x] **Step 3: Update agent instruction surfaces**
Update `skills/wf-cli/SKILL.md` and the two `wf-workflow` references with the
same canonical examples and decision rule:
```text
Use set-workflow-output without --merge when replacing the complete public
output projection. Use --bindings-file when exact path/value interleaving must
round-trip. Use --merge --map only when a lossy compatibility edit is acceptable.
```
Do not direct agents to implementation files or tests.
- [x] **Step 4: Update roadmap and archive the checked plan**
Add a completed milestone to `docs/current_roadmap.md` linking to:
```text
historical/superpowers/plans/2026-07-23-atomic-workflow-output-bindings.md
```
Mark every completed checkbox in this plan, then move it to the historical path.
Search for the old active-plan path and update any live links:
```bash
rg -n "2026-07-23-atomic-workflow-output-bindings" docs skills README.md
```
- [x] **Step 5: Run the complete focused 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_tools.py \
tests/wf_mcp/server/test_config.py \
tests/wf_cli/test_app.py \
tests/wf_cli/test_remote_target.py \
-q
```
Expected: PASS with only already-known dependency deprecation warnings.
If the Windows `uv.exe` app alias is broken, use the repository environment:
```powershell
.venv\Scripts\python.exe -m pytest -n 0 `
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_tools.py `
tests/wf_mcp/server/test_config.py `
tests/wf_cli/test_app.py `
tests/wf_cli/test_remote_target.py `
-q
```
- [x] **Step 6: Run static verification**
Run:
```bash
uv run ruff check
uv run ruff format --check
uv run basedpyright --level error
git diff --check
```
Expected: all clean. Remove only verified workspace-local `.pytest-*` temporary
directories before Ruff if an interrupted pytest run left generated fixture
files under the repository root.
- [x] **Step 7: Run independent whole-slice review**
Review from the pre-plan implementation base through `HEAD` against
`docs/superpowers/specs/2026-07-23-atomic-workflow-output-bindings-design.md`.
Require findings first with severity and file/line references. Specifically ask
the reviewer to inspect:
- implicit fallback after `[]`;
- literal no-inference behavior;
- context target requirements;
- root target equality and overlap;
- nested `$ref` projection;
- stale revision and no-op semantics;
- path/value ordering through RPC, MCP, and CLI;
- compatibility `--merge --map` isolation;
- live docs and issue truthfulness.
Fix all Critical and Important findings, rerun affected tests/static checks, and
repeat focused review until both Standards and Spec pass.
- [x] **Step 8: Commit documentation and any final review fixes**
Commit the documentation/archive change:
```bash
git add ISSUES.md docs skills
git commit -m "docs: complete atomic workflow output bindings"
```
If review fixes modify production/tests, commit them separately with a scoped
message after rerunning verification. Finish with:
```bash
git status --short
git log --oneline -10
```
Expected: clean worktree and a readable task-level commit sequence.