28 KiB
Atomic Step 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 (
- [ ]) syntax for tracking.
Goal: Add one revision-checked operation that atomically replaces a capability step's ordered canonical output bindings while projecting missing workflow state schemas.
Architecture: Reuse the core OutputBinding model from API through transport. Put capability-aware source validation and state-schema projection in WorkflowDraftAuthoringApi, use the existing wf_api.schema_projection helpers, and retain the map operation only as a compatibility adapter. The CLI lowers ordered 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 another output-binding model or mapping language.
- Preserve canonical binding order and permit one local source to feed several distinct state targets.
- Reject duplicate and ancestor/descendant state targets before mutation.
- Validate local sources against the selected capability output schema.
- Project missing state target schemas atomically; never delete state schema when replacing or clearing bindings.
- Accept an existing target schema only when it is exactly equal to the selected source fragment.
- Envelope validation precedes revision checking; stale revision then precedes workspace, capability, and schema semantics.
- Changed replacements advance exactly one revision; exact replacements are revision-checked no-ops.
- Keep the legacy map API/RPC and
--mergebehavior operational for real callers and label it compatibility-only and lossy. - Do not change workflow-output bindings, runtime binding models, TypeScript RPC, or revision history.
- Add comments around whole-payload projection, monotonic state-schema projection, and CLI compatibility dispatch.
Task 1: Atomic Capability-Aware Output 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 - Test:
tests/core/test_atomic_state_patches.py
Interfaces:
-
Consumes:
OutputBinding,schema_fragment_at_path,schema_path_exists,project_schema_path_to_schema_path,WorkflowDraftAuthoringApi._workspace_if_revision_matches, andWorkflowDraftApi.patch_draft_workspace. -
Produces:
async def set_step_output_bindings( *, workspace_id: str, revision: int, step_id: str, bindings: Sequence[OutputBinding], ) -> dict[str, Any]: ... -
Preserves:
set_step_output_map(..., merge=...)unchanged as a compatibility operation. -
Step 1: Write failing canonical replacement and fan-out tests
Add tests alongside the canonical input-binding tests. Use an existing test capability whose output schema contains a nested object, or register one with an output contract equivalent to:
{ "type": "object", "properties": { "report": { "type": "object", "properties": { "title": {"type": "string"}, "markdown": {"type": "string"}, }, "required": ["title", "markdown"], } }, "required": ["report"], }Pin exact stored order and source fan-out:
result = await api.set_step_output_bindings( workspace_id="draft-output-bindings", revision=1, step_id="analyze", bindings=[ OutputBinding( source=LocalPath.parse("report.title"), target=StatePath.parse("state.report.title"), ), OutputBinding( source=LocalPath.parse("report.title"), target=StatePath.parse("state.audit.title"), ), ], ) assert result["revision"] == 2 inspected = await api.get_draft_workspace( workspace_id="draft-output-bindings", include_draft=True, ) assert inspected["draft"]["steps"]["analyze"]["output"] == [ {"source": "report.title", "target": "state.report.title"}, {"source": "report.title", "target": "state.audit.title"}, ] -
Step 2: Write failing schema projection tests
Cover nested source-to-target projection, whole-payload
.projection, exact-equivalent existing target acceptance, and incompatible existing target rejection. Assert projected fields rather than whole schema equality:state_schema = inspected["draft"]["state_schema"] assert state_schema["properties"]["report"]["properties"]["title"] == { "type": "string" } assert state_schema["properties"]["audit"]["properties"]["title"] == { "type": "string" }For whole payload:
bindings=[ OutputBinding( source=LocalPath.root(), target=StatePath.parse("state.raw_result"), ) ]Verify the complete capability output object appears below
state_schema.properties.raw_result. -
Step 3: Write failing semantic-error and revision tests
Add cases for:
- missing local source;
- duplicate state target;
state.reportoverlappingstate.report.title;- incompatible existing state target schema;
- missing or non-capability step;
- stale revision combined with each semantic error class;
- unchanged draft and revision after every failure;
- exact replacement returning a no-op summary at the current revision;
bindings=[]clearing outputs without deleting projected state fields.
Error assertions should include stable binding indexes:
with pytest.raises( ValueError, match=r"bindings\[0\]\.target 'state\.report' overlaps " r"bindings\[1\]\.target 'state\.report\.title'", ): await api.set_step_output_bindings(...) -
Step 4: Run focused API tests and confirm RED
Run:
uv run pytest tests/wf_api/test_drafts_service.py -q -k "step_output"Expected: failures because
WorkflowApi.set_step_output_bindingsdoes not exist. -
Step 5: Add output overlap and patch helpers
In
src/wf_api/draft_authoring.py, add focused helpers beside the input equivalents:def _overlapping_output_targets_error( bindings: Sequence[OutputBinding], ) -> ValueError: """Describe the first overlapping state-target pair with stable indexes.""" for left_index, left in enumerate(bindings): for right_index in range(left_index + 1, len(bindings)): right = bindings[right_index] if paths_overlap(str(left.target), str(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_output_bindings_patch( *, workspace: WorkflowDraftWorkspace, step_id: str, bindings: list[dict[str, Any]], state_schema: dict[str, Any], ) -> list[dict[str, Any]]: """Build one atomic patch for state schema and canonical step outputs.""" patch: list[dict[str, Any]] = [] if workspace.draft.get("state_schema", {}) != state_schema: patch.append( {"op": "replace", "path": "/state_schema", "value": state_schema} ) patch.append( { "op": "replace", "path": f"/steps/{escape_json_pointer(step_id)}/output", "value": bindings, } ) return patchpaths_overlapoperates on rootless path syntax, so passing both serializedstate.*values gives it a shared synthetic root and preserves equality and ancestry checks. Keep this comment at the helper call because the type seam is otherwise non-obvious. -
Step 6: Implement canonical output replacement
Add
WorkflowDraftAuthoringApi.set_step_output_bindings:async def set_step_output_bindings( self, *, workspace_id: str, revision: int, step_id: str, bindings: Sequence[OutputBinding], ) -> dict[str, Any]: """Replace one capability step's canonical output 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.output_schema_contract or spec.output_model.model_json_schema() ) targets = [str(binding.target) for binding in bindings] if has_overlapping_paths(targets): raise _overlapping_output_targets_error(bindings) projected_state = _draft_schema(workspace.draft, "state_schema") for index, binding in enumerate(bindings): source_parts = binding.source.parts try: schema_fragment_at_path( capability_schema, source_parts, label="capability output schema", ) except ValueError as exc: raise ValueError( f"bindings[{index}].source {str(binding.source)!r} " f"is not declared by capability {capability_name!r}: {exc}" ) from exc target_parts = binding.target.parts try: projected_state = project_schema_path_to_schema_path( target_schema=projected_state, source_schema=capability_schema, source_parts=source_parts, target_parts=target_parts, allow_existing_equivalent=True, ) except ValueError as exc: raise ValueError( f"bindings[{index}].target {str(binding.target)!r} " f"cannot receive source {str(binding.source)!r}: {exc}" ) from exc payload = [binding.model_dump(mode="json") for binding in bindings] if ( step.get("output", []) == payload and workspace.draft.get("state_schema", {}) == projected_state ): return summarize_draft_workspace(workspace) return await self.drafts.patch_draft_workspace( workspace_id=workspace_id, revision=revision, patch=_step_output_bindings_patch( workspace=workspace, step_id=step_id, bindings=payload, state_schema=projected_state, ), )The unconditional projection call naturally inserts missing targets and accepts only exact existing targets. Wrap its errors with the binding index, target, and source so incompatible-schema diagnostics satisfy the public error contract.
-
Step 7: Expose the operation through the API protocol and service
Add the same
Sequence[OutputBinding]signature toWorkflowApiSurfaceand delegate fromWorkflowApitoself.draft_authoringexactly as the canonical input operation does. -
Step 8: Add compile-and-run fan-out coverage
In
tests/core/test_atomic_state_patches.py, add a focusedapply_output_bindingsregression where one local source is bound to two state targets. Assert both state values after application. The authoring API test separately proves the same canonical list compiles. Do not only inspect serialized draft JSON. -
Step 9: Run API and runtime tests
Run:
uv run pytest tests/wf_api/test_drafts_service.py tests/core/test_atomic_state_patches.py -qExpected: PASS.
-
Step 10: Commit Task 1
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 tests/core/test_atomic_state_patches.py git commit -m "feat: replace draft step output bindings"
Task 2: JSON-RPC Model, Method, And Remote Client
Files:
- Modify:
src/wf_transport_rpc_http/models.py - Modify:
src/wf_transport_rpc_http/__init__.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: Task 1
WorkflowApi.set_step_output_bindingsand coreOutputBinding. -
Produces:
class SetStepOutputBindingsParams(RpcParamsModel): workspace_id: str revision: int step_id: str bindings: list[OutputBinding]and remote client method
set_step_output_bindings(...). -
Step 1: Write failing RPC application tests
Add a request containing duplicate sources and ordered distinct targets:
response = await _rpc( app, "workflow.draft_workspaces.set_step_output_bindings", { "workspace_id": "draft-rpc-output-bindings", "revision": 1, "step_id": "analyze", "bindings": [ {"source": "report.title", "target": "state.report.title"}, {"source": "report.title", "target": "state.audit.title"}, ], }, )Inspect the workspace and assert exact list order. Add malformed requests for missing
source, bare state target, unexpected fields, and invalid root marker. These must return JSON-RPC invalid-params errors without invoking the semantic method. -
Step 2: Write failing remote client tests
Call the typed client with two
OutputBindingvalues sharing a source. Assert the recorded wire method and JSON payload exactly:assert call["method"] == ( "workflow.draft_workspaces.set_step_output_bindings" ) assert call["params"]["bindings"] == [ {"source": "report.title", "target": "state.report.title"}, {"source": "report.title", "target": "state.audit.title"}, ] -
Step 3: Run RPC tests and confirm RED
uv run pytest tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py -q -k "step_output"Expected: unknown method/model/client failures.
-
Step 4: Add the typed parameter model and exports
Import
OutputBindinginmodels.py, defineSetStepOutputBindingsParams, and add it towf_transport_rpc_http.__init__imports and__all__. -
Step 5: Register the JSON-RPC method
In
methods/drafts.py, register:@method( name="workflow.draft_workspaces.set_step_output_bindings", params_model=SetStepOutputBindingsParams, ) async def workflow_draft_workspaces_set_step_output_bindings( params: SetStepOutputBindingsParams = RpcParams(), ) -> dict[str, Any]: return await server.api.set_step_output_bindings( workspace_id=params.workspace_id, revision=params.revision, step_id=params.step_id, bindings=params.bindings, )Follow the existing decorator signature exactly if it differs from this abbreviated example.
-
Step 6: Add the remote client method
Mirror
set_step_input_bindingsand serialize each binding withmodel_dump(mode="json"). Do not lower throughoutput_map. -
Step 7: Run RPC tests
uv run pytest tests/wf_transport_rpc_http/test_app.py tests/wf_transport_rpc_http/test_client.py -qExpected: PASS.
-
Step 8: Commit Task 2
git add src/wf_transport_rpc_http tests/wf_transport_rpc_http git commit -m "feat: expose output bindings over rpc"
Task 3: MCP Canonical Output 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_tools.py - Test:
tests/wf_mcp/server/test_config.py
Interfaces:
-
Consumes: Task 1
WorkflowApiSurface.set_step_output_bindingsand coreOutputBinding. -
Produces:
class SetStepOutputBindingsRequest(BaseModel): workspace_id: WorkspaceId revision: int step_id: NonEmptyString bindings: DraftOutputBindingsand MCP tool
wf.workflow.set_step_output_bindings. -
Step 1: Write failing request-model tests
Validate ordered fan-out and reject malformed canonical records:
request = SetStepOutputBindingsRequest.model_validate( { "workspace_id": "draft-output", "revision": 4, "step_id": "analyze", "bindings": [ {"source": "report.title", "target": "state.report.title"}, {"source": "report.title", "target": "state.audit.title"}, ], } ) assert [str(binding.source) for binding in request.bindings] == [ "report.title", "report.title", ] -
Step 2: Write failing handler and discovery tests
Use the workflow-surface handler fake to assert the tool delegates once with typed bindings in order. Add the tool name to server discovery/config tests and the always-visible proxy list assertion.
-
Step 3: Run focused MCP tests and confirm RED
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 "output_bindings or tools or config" -
Step 4: Add the MCP request model
Define
SetStepOutputBindingsRequestbeside the map request and describe it as complete ordered replacement. ReuseDraftOutputBindings; do not define a second union or path-map type. -
Step 5: Register the MCP tool
Add:
@mcp.tool( name="wf.workflow.set_step_output_bindings", description=( "Replace one capability step's complete ordered output bindings. " "Repeated sources are valid fan-out; state targets must not overlap." ), ) async def set_step_output_bindings( request: SetStepOutputBindingsRequest, ) -> dict[str, Any]: return dict( await handlers.set_step_output_bindings( workspace_id=request.workspace_id, revision=request.revision, step_id=request.step_id, bindings=request.bindings, ) )Follow local registration and result-conversion conventions exactly.
-
Step 6: Pin the tool in proxy discovery
Add
wf.workflow.set_step_output_bindingsbeside input bindings in_SEARCH_ALWAYS_VISIBLE_TOOL_NAMES. -
Step 7: Run MCP tests
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 -qExpected: PASS.
-
Step 8: Commit Task 3
git add src/wf_mcp tests/wf_mcp git commit -m "feat: expose canonical output bindings to mcp"
Task 4: Typer Replacement Modes And Remote CLI
Files:
- Modify:
src/wf_cli/commands/draft_options.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: core
OutputBinding, Task 1 local API operation, and Task 2 remote client method. -
Produces:
def parse_step_output_binding_flags( values: list[str] | None, ) -> list[OutputBinding]: ... def parse_step_output_bindings_file(path: Path) -> list[OutputBinding]: ... -
Keeps:
wf draft set-output; no new command name. -
Step 1: Write failing parser tests
Add tests proving repeated sources remain separate and file order is exact:
bindings = parse_step_output_binding_flags( [ "report.title=state.report.title", "report.title=state.audit.title", ] ) assert [binding.model_dump(mode="json") for binding in bindings] == [ {"source": "report.title", "target": "state.report.title"}, {"source": "report.title", "target": "state.audit.title"}, ]File tests must reject a JSON object, missing fields, unexpected fields, invalid local source syntax, and non-state targets as concise
typer.BadParametererrors. -
Step 2: Write failing local command tests
Cover:
- repeated
--mapcanonical replacement; --bindings-filereplacement;- explicit
--clear; - no mode selected;
--bindings-fileor--clearcombined with--map;--mergecombined with file/clear;--merge --mapdispatching toset_step_output_maponly;- duplicate-source fan-out dispatching to
set_step_output_bindingsonly.
- repeated
-
Step 3: Write failing remote-target tests
Run the same repeated-source and file modes with
--target. Assert the RPC method is exactlyworkflow.draft_workspaces.set_step_output_bindings, called once, with list order preserved. Retain one compatibility--mergeassertion forset_step_output_map. -
Step 4: Run focused CLI tests and confirm RED
uv run pytest tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py -q -k "set_output" -
Step 5: Add output binding parsers
In
draft_options.py, importOutputBinding, add aTypeAdapter(list[OutputBinding]), parse ordered flags directly rather than through_parse_assignment_flags, and translate Pydantic/path failures withvalidation_error_as_bad_parameter:def parse_step_output_binding_flags( values: list[str] | None, ) -> list[OutputBinding]: """Parse ordered local-to-state outputs without collapsing fan-out.""" bindings: list[OutputBinding] = [] for item in values or []: source, separator, target = item.partition("=") if separator != "=" or not source or not target: raise typer.BadParameter( "--map must use LOCAL_SOURCE=STATE_TARGET" ) try: bindings.append( OutputBinding( source=LocalPath.parse(source), target=StatePath.parse(target), ) ) except (PathResolutionError, ValidationError) as exc: raise typer.BadParameter(str(exc)) from exc return bindingsparse_step_output_bindings_filevalidatesparse_json_file(...)through the adapter exactly as the input file parser does. -
Step 6: Rework
wf draft set-outputdispatchAdd
--bindings-file: Path | Noneand--clear: bool. Use this mode matrix:has_maps = bool(mapping) has_file = bindings_file is not None selected_modes = sum((has_maps, has_file, clear)) if selected_modes == 0: raise typer.BadParameter("provide --map, --bindings-file, or --clear") if selected_modes > 1: raise typer.BadParameter( "--bindings-file and --clear cannot be combined with --map" ) if merge and (has_file or clear): raise typer.BadParameter( "--merge is supported only for compatibility map-only edits" )Dispatch
--merge --mapthrough_parse_output_map_flagsandset_step_output_map. Dispatch every replacement mode through canonical bindings andset_step_output_bindings. The no-flags case must no longer silently clear outputs; clearing requires--clear. -
Step 7: Update command help tests
Pin direction and semantics:
--map LOCAL_SOURCE=STATE_TARGET --bindings-file ordered canonical JSON array --clear replace with no bindings --merge compatibility-only and potentially lossy -
Step 8: Run CLI tests
uv run pytest tests/wf_cli/test_app.py tests/wf_cli/test_remote_target.py -qExpected: PASS.
-
Step 9: Commit Task 4
git add src/wf_cli tests/wf_cli git commit -m "feat: replace draft output bindings from cli"
Task 5: Documentation, Issue State, Review, And Final Verification
Files:
- Modify:
ISSUES.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/workflow_capabilities.md - Modify:
docs/wf_mcp_operator_manual.md - Modify:
docs/current_roadmap.md - Move after completion:
docs/superpowers/plans/2026-07-23-atomic-step-output-bindings.mdtodocs/historical/superpowers/plans/2026-07-23-atomic-step-output-bindings.md
Interfaces:
-
Consumes: all completed implementation tasks and their verified command names.
-
Produces: current user/agent guidance, accurate issue state, archived checked plan, and a final verification report.
-
Step 1: Update issue language without falsely closing map loss
Keep the combined issue unchecked and revise it to state:
- [ ] Compatibility step input/output maps can still collapse valid canonical fan-out bindings. Canonical input and output replacement preserve ordered fan-out, but later compatibility-map merges remain inherently lossy.Leave workflow-output literals, nested workflow-output projection, focused step updates, and TypeScript parity unchecked.
-
Step 2: Update CLI and agent documentation
Replace descriptions that say
set-outputreplaces a map with canonical-list language. Include examples for repeated-source fan-out, file round-trip, explicit clear, and compatibility merge:wf draft set-output WS --revision 4 --step analyze \ --map report.title=state.report.title \ --map report.title=state.audit.title wf draft inspect WS --include-draft | jq '.draft.steps.analyze.output' > output-bindings.json wf draft set-output WS --revision 5 --step analyze \ --bindings-file output-bindings.json wf draft set-output WS --revision 6 --step analyze --clearExplicitly warn that
--merge --mapis compatibility-only and may collapse existing fan-out. -
Step 3: Update RPC/MCP operation inventories
Add
workflow.draft_workspaces.set_step_output_bindingsandwf.workflow.set_step_output_bindingsto user-facing inventories and examples near their input counterparts. -
Step 4: Run focused matrix verification
uv run pytest \ 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 \ -qExpected: PASS with only already-known dependency deprecation warnings.
-
Step 5: Run static verification
uv run ruff check uv run ruff format --check uv run basedpyright --level error git diff --checkExpected: all clean.
-
Step 6: Run independent review
Use the repository code-review skill against the pre-slice commit. Fix every Critical or Important finding. Record Minor deferrals with concrete reasons in the final report.
-
Step 7: Update roadmap and archive this plan
Add a completed roadmap entry linking to:
historical/superpowers/plans/2026-07-23-atomic-step-output-bindings.mdCheck every completed plan box, move the plan under
docs/historical/, and verify no live link still points to its old location. -
Step 8: Commit documentation and completion state
git add ISSUES.md skills docs git commit -m "docs: complete atomic step output bindings" -
Step 9: Final repository check
git status --short git log -7 --onelineExpected: clean worktree and one coherent commit per task plus any explicitly identified review-fix commit.
Plan Self-Review
- The plan implements every approved spec surface: Python API, RPC, MCP, CLI, compatibility behavior, docs, and issue state.
- The canonical model stays
OutputBinding; no map or new union appears on the replacement path. - State schema projection uses the existing shared helper and is monotonic.
- Fan-out is tested at serialization and runtime boundaries.
- Revision precedence, no-op behavior, clear behavior, and no-mutation failures are explicit.
- Workflow-output authoring, focused step updates, and TypeScript parity remain separate slices.