# Self-Describing Interrupt Contracts 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 machine-readable interrupt request/resume schemas to workflow definitions, persisted interrupt inspection, and resume validation. **Architecture:** Store JSON Schema dictionaries on `InterruptNode`, validate request/resume payloads with the existing runtime JSON Schema helper, and copy the contract into `InterruptRequest` so durable run inspection is self-contained. Keep legacy workflows compatible by defaulting missing schemas to permissive object schemas and marking them `typed=false`. **Tech Stack:** Python 3.14, Pydantic, `jsonschema`, pytest, Typer CLI, JSON-RPC HTTP transport. --- ## Scope Implement the prerequisite platform contract from [`docs/superpowers/specs/2026-07-01-self-describing-interrupt-contracts.md`](../specs/2026-07-01-self-describing-interrupt-contracts.md). Do not build the web console, the demo agent, or the prepared `lda.chat` report workflow in this plan. This slice only makes interrupts self-describing. ## File Structure - Modify `src/wf_core/models/steps.py` - Add `request_schema`, `resume_schema`, and `typed`-derivation helpers to `InterruptNode`. - Modify `src/wf_core/run_state.py` - Add interrupt contract fields to `InterruptRequest`. - Modify `src/wf_core/runtime/ops/interrupts.py` - Validate request payloads before pausing. - Validate resume payloads before state mutation. - Copy schemas/outcomes/typed flag into `InterruptRequest`. - Modify `src/wf_core/validation/steps.py` - Validate interrupt schema objects during workflow validation. - Modify `src/wf_api/runs.py` - Ensure `inspect_run`, `run start`, `trace`, and `resume` include JSON-safe interrupt contract fields. - Modify `src/wf_cli/commands/runs.py` - Keep JSON output as source of truth. - Improve `resume` docstring/help to mention schema validation. - Modify docs and skills: - `docs/wf_cli.md` - `docs/current_roadmap.md` - `skills/wf-cli/SKILL.md` - `skills/wf-workflow/references/workflow-lifecycle.md` - Tests: - `tests/core/test_canonical_node_bindings.py` - `tests/core/test_execution_results.py` - `tests/core/test_run_codec.py` - `tests/core/test_subgraph_step.py` - `tests/wf_api/test_run_api.py` - `tests/wf_cli/test_run_deploy.py` - `tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py` ## Behavioral Contract Use this permissive schema for legacy/untyped interrupts: ```python def _object_schema() -> dict[str, object]: return {"type": "object", "additionalProperties": True} ``` An interrupt is typed when the original node payload explicitly includes either `request_schema` or `resume_schema`. Runtime validation rules: - request payload validation happens after request bindings build `payload` and before setting `run.interrupt`; - resume payload validation happens after resolving the paused `InterruptNode` and before `build_output_patch`; - validation uses `wf_core.runtime.ops.schemas.validate_payload_against_schema`; - invalid payloads raise `WorkflowExecutionError` through the existing runtime error path; - no state mutation or trace append happens when resume payload validation fails. --- ### Task 1: Model Defaults And Serialization **Files:** - Modify: `src/wf_core/models/steps.py` - Test: `tests/core/test_canonical_node_bindings.py` - [ ] **Step 1: Add failing tests for interrupt schemas** Add these tests near the existing interrupt binding tests in `tests/core/test_canonical_node_bindings.py`: ```python def test_interrupt_node_defaults_to_untyped_object_contract(): node = InterruptNode.model_validate( { "id": "approval", "type": "interrupt", "kind": "approval", } ) assert node.request_schema == { "type": "object", "additionalProperties": True, } assert node.resume_schema == { "type": "object", "additionalProperties": True, } assert node.has_explicit_contract is False dumped = node.model_dump(mode="json") assert dumped["request_schema"] == { "type": "object", "additionalProperties": True, } assert dumped["resume_schema"] == { "type": "object", "additionalProperties": True, } def test_interrupt_node_accepts_explicit_request_and_resume_schemas(): node = InterruptNode.model_validate( { "id": "approval", "type": "interrupt", "kind": "approval", "request_schema": { "type": "object", "properties": {"message": {"type": "string"}}, "required": ["message"], "additionalProperties": False, }, "resume_schema": { "type": "object", "properties": {"approved": {"type": "boolean"}}, "required": ["approved"], "additionalProperties": False, }, } ) assert node.has_explicit_contract is True assert node.request_schema["required"] == ["message"] assert node.resume_schema["required"] == ["approved"] ``` - [ ] **Step 2: Run tests and verify red** Run: ```powershell uv run pytest tests/core/test_canonical_node_bindings.py::test_interrupt_node_defaults_to_untyped_object_contract tests/core/test_canonical_node_bindings.py::test_interrupt_node_accepts_explicit_request_and_resume_schemas -q -n0 ``` Expected: both fail because `InterruptNode` has no schema fields. - [ ] **Step 3: Add model fields and explicit-contract detection** In `src/wf_core/models/steps.py`, add this helper near `InterruptNode`: ```python def _object_schema() -> dict[str, object]: """Default legacy interrupt contract: any JSON object payload is accepted.""" return {"type": "object", "additionalProperties": True} ``` Update `InterruptNode`: ```python class InterruptNode(BaseModel): """Control-flow step that pauses a run and waits for resume input.""" id: str type: Literal["interrupt"] kind: str request: list[InputBinding] = Field( default_factory=list, description=( "Bindings that build the interrupt request payload sent to the client." ), ) resume: list[OutputBinding] = Field( default_factory=list, description=( "Bindings that commit resume payload fields back into workflow state." ), ) outcomes: list[str] = Field(default_factory=lambda: ["submitted"]) request_schema: dict[str, object] = Field(default_factory=_object_schema) resume_schema: dict[str, object] = Field(default_factory=_object_schema) has_explicit_contract: bool = Field(default=False, exclude=True) ``` In `_coerce_deprecated_maps`, before returning `normalized`, add: ```python normalized["has_explicit_contract"] = ( "request_schema" in normalized or "resume_schema" in normalized ) ``` This preserves explicitness before defaults are applied. - [ ] **Step 4: Run model tests and verify green** Run: ```powershell uv run pytest tests/core/test_canonical_node_bindings.py::test_interrupt_node_defaults_to_untyped_object_contract tests/core/test_canonical_node_bindings.py::test_interrupt_node_accepts_explicit_request_and_resume_schemas -q -n0 ``` Expected: both pass. - [ ] **Step 5: Commit** ```powershell git add src/wf_core/models/steps.py tests/core/test_canonical_node_bindings.py git commit -m "feat: add interrupt schema contract fields" ``` --- ### Task 2: Validate Interrupt Schemas Statically **Files:** - Modify: `src/wf_core/validation/steps.py` - Test: `tests/core/test_canonical_node_bindings.py` - [ ] **Step 1: Add failing test for invalid schema rejection** Add this test to `tests/core/test_canonical_node_bindings.py`: ```python def test_interrupt_node_rejects_invalid_json_schema_contract(): with pytest.raises(ValidationError, match="invalid JSON Schema"): InterruptNode.model_validate( { "id": "approval", "type": "interrupt", "kind": "approval", "resume_schema": { "type": "object", "properties": {"approved": {"type": "not-a-json-schema-type"}}, }, } ) ``` - [ ] **Step 2: Run test and verify red** Run: ```powershell uv run pytest tests/core/test_canonical_node_bindings.py::test_interrupt_node_rejects_invalid_json_schema_contract -q -n0 ``` Expected: fail because plain dict schema fields are not checked. - [ ] **Step 3: Add schema field validators** In `src/wf_core/models/steps.py`, add imports: ```python from jsonschema import Draft202012Validator, SchemaError, validators from pydantic import field_validator ``` Extend the existing Pydantic import instead of creating a second import. Add this helper near `_object_schema`: ```python def _validate_json_schema(value: object, *, field_name: str) -> dict[str, object]: """Validate one interrupt contract schema with jsonschema.""" if not isinstance(value, Mapping): raise ValueError(f"{field_name} must be a JSON Schema object") schema = dict(value) validator_cls = ( validators.validator_for(schema) if "$schema" in schema else Draft202012Validator ) try: validator_cls.check_schema(schema) except SchemaError as exc: raise ValueError(f"invalid JSON Schema: {exc.message}") from exc schema_type = schema.get("type") if schema_type != "object": raise ValueError(f"{field_name} must describe a JSON object") return schema ``` Add validators inside `InterruptNode`: ```python @field_validator("request_schema", "resume_schema", mode="before") @classmethod def _validate_interrupt_schema(cls, value: object, info: object) -> object: field_name = getattr(info, "field_name", "interrupt schema") return _validate_json_schema(value, field_name=field_name) ``` - [ ] **Step 4: Run test and verify green** Run: ```powershell uv run pytest tests/core/test_canonical_node_bindings.py::test_interrupt_node_rejects_invalid_json_schema_contract tests/core/test_canonical_node_bindings.py::test_interrupt_node_accepts_explicit_request_and_resume_schemas -q -n0 ``` Expected: pass. - [ ] **Step 5: Commit** ```powershell git add src/wf_core/models/steps.py tests/core/test_canonical_node_bindings.py git commit -m "fix: validate interrupt schema contracts" ``` --- ### Task 3: Persist Interrupt Contract In Run State **Files:** - Modify: `src/wf_core/run_state.py` - Modify: `src/wf_core/runtime/ops/interrupts.py` - Test: `tests/core/test_run_codec.py` - [ ] **Step 1: Add failing codec test** Add this test to `tests/core/test_run_codec.py`: ```python def test_run_state_codec_round_trips_interrupt_contract_fields() -> None: run = RunState( workflow_name="approval", status=RunStatus.INTERRUPTED, workflow_input={}, state={}, interrupt=InterruptRequest( id="interrupt:approval", frame_id="frame_1", node_id="approval", kind="approval", payload={"message": "approve?"}, outcomes=["submitted", "cancelled"], request_schema={ "type": "object", "properties": {"message": {"type": "string"}}, "required": ["message"], }, resume_schema={ "type": "object", "properties": {"approved": {"type": "boolean"}}, "required": ["approved"], }, typed=True, ), ) restored = decode_run_state(encode_run_state(run)) assert restored.interrupt is not None assert restored.interrupt.outcomes == ["submitted", "cancelled"] assert restored.interrupt.request_schema["required"] == ["message"] assert restored.interrupt.resume_schema["required"] == ["approved"] assert restored.interrupt.typed is True ``` If `tests/core/test_run_codec.py` uses differently named codec helpers, use the existing helpers in that file and keep the assertions unchanged. - [ ] **Step 2: Run test and verify red** Run: ```powershell uv run pytest tests/core/test_run_codec.py::test_run_state_codec_round_trips_interrupt_contract_fields -q -n0 ``` Expected: fail because `InterruptRequest` lacks the new fields. - [ ] **Step 3: Extend `InterruptRequest`** In `src/wf_core/run_state.py`, add: ```python def _object_schema() -> dict[str, object]: """Default legacy interrupt contract used when older checkpoints are loaded.""" return {"type": "object", "additionalProperties": True} ``` Update `InterruptRequest`: ```python @dataclass(slots=True) class InterruptRequest: id: str frame_id: str node_id: str kind: str payload: dict[str, Any] = field(default_factory=dict) resumable: bool = True route: InterruptRoute | None = None outcomes: list[str] = field(default_factory=lambda: ["submitted"]) request_schema: dict[str, object] = field(default_factory=_object_schema) resume_schema: dict[str, object] = field(default_factory=_object_schema) typed: bool = False ``` - [ ] **Step 4: Copy contract fields when building interrupt requests** In `src/wf_core/runtime/ops/interrupts.py`, update the `InterruptRequest(...)` construction: ```python return InterruptRequest( id=f"interrupt:{public_node_id or node.id}", frame_id=public_frame_id or frame_id, node_id=public_node_id or node.id, kind=node.kind, payload=payload, route=route, outcomes=list(node.outcomes), request_schema=dict(node.request_schema), resume_schema=dict(node.resume_schema), typed=node.has_explicit_contract, ) ``` - [ ] **Step 5: Run codec and existing interrupt tests** Run: ```powershell uv run pytest tests/core/test_run_codec.py tests/core/test_execution_results.py -q -n0 ``` Expected: pass. - [ ] **Step 6: Commit** ```powershell git add src/wf_core/run_state.py src/wf_core/runtime/ops/interrupts.py tests/core/test_run_codec.py git commit -m "feat: persist interrupt contract in run state" ``` --- ### Task 4: Runtime Request Payload Validation **Files:** - Modify: `src/wf_core/runtime/ops/interrupts.py` - Test: `tests/core/test_execution_results.py` - [ ] **Step 1: Add failing request-schema runtime test** Add this test to `tests/core/test_execution_results.py`: ```python async def test_interrupt_request_payload_validates_against_schema() -> None: workflow = Workflow( name="bad_interrupt_request", input_schema={"type": "object", "properties": {}}, state_schema={"fields": {}}, output_schema={"type": "object", "properties": {}}, outcomes=["failed"], start="ask", nodes=[ InterruptNode( id="ask", type="interrupt", kind="approval", request=[input_value_binding("not a number", "count")], request_schema={ "type": "object", "properties": {"count": {"type": "number"}}, "required": ["count"], "additionalProperties": False, }, ) ], edges=[], ) run = await execute_workflow_async(workflow, {}, {}) assert run.status == RunStatus.FAILED assert run.interrupt is None assert run.error is not None assert "interrupt request for ask['count']" in run.error ``` Use existing imports/helpers in the file. If `input_value_binding` is not available, import it from `wf_core.models.steps` or construct `InputValueBinding(target="count", value="not a number")`. - [ ] **Step 2: Run test and verify red** Run: ```powershell uv run pytest tests/core/test_execution_results.py::test_interrupt_request_payload_validates_against_schema -q -n0 ``` Expected: fail because request payload is not validated. - [ ] **Step 3: Validate request payload before returning `InterruptRequest`** In `src/wf_core/runtime/ops/interrupts.py`, import: ```python from wf_core.runtime.ops.schemas import validate_payload_against_schema ``` After request bindings build `payload` and before `return InterruptRequest(...)`, add: ```python validate_payload_against_schema( node.request_schema, payload, f"interrupt request for {node.id}", ) ``` - [ ] **Step 4: Run request-validation tests** Run: ```powershell uv run pytest tests/core/test_execution_results.py::test_interrupt_request_payload_validates_against_schema tests/authoring/test_demo_workflow.py::test_interrupt_then_resume_to_send_email -q -n0 ``` Expected: pass. - [ ] **Step 5: Commit** ```powershell git add src/wf_core/runtime/ops/interrupts.py tests/core/test_execution_results.py git commit -m "fix: validate interrupt request payloads" ``` --- ### Task 5: Runtime Resume Payload Validation **Files:** - Modify: `src/wf_core/runtime/ops/interrupts.py` - Test: `tests/core/test_execution_results.py` - [ ] **Step 1: Add failing resume-schema runtime test** Add this test to `tests/core/test_execution_results.py`: ```python async def test_interrupt_resume_payload_validates_before_state_mutation() -> None: workflow = Workflow( name="resume_validation", input_schema={"type": "object", "properties": {}}, state_schema={ "type": "object", "properties": { "approved": {"type": "boolean", "reducer": "wf.std.replace"} }, }, output_schema={"type": "object", "properties": {}}, outcomes=["submitted"], start="ask", nodes=[ InterruptNode( id="ask", type="interrupt", kind="approval", resume=[output_binding("approved", "state.approved")], resume_schema={ "type": "object", "properties": {"approved": {"type": "boolean"}}, "required": ["approved"], "additionalProperties": False, }, ), EndNode(id="end", type="end", outcome="submitted"), ], edges=[Edge.model_validate({"from": "ask", "outcome": "submitted", "to": "end"})], ) interrupted = await execute_workflow_async(workflow, {}, {}) resumed = await resume_workflow_async( workflow, interrupted, {"approved": "yes"}, {}, resume_outcome="submitted", ) assert resumed.status == RunStatus.FAILED assert resumed.state == {} assert resumed.error is not None assert "interrupt resume for ask['approved']" in resumed.error ``` Use the existing edge/binding helper style in the file. If `EndNode` or `output_binding` is not imported, import them from existing core modules used by the file. - [ ] **Step 2: Run test and verify red** Run: ```powershell uv run pytest tests/core/test_execution_results.py::test_interrupt_resume_payload_validates_before_state_mutation -q -n0 ``` Expected: fail because resume payload is not validated before bindings. - [ ] **Step 3: Validate resume payload before `build_output_patch`** In `resume_interrupt()` in `src/wf_core/runtime/ops/interrupts.py`, after the `resume_outcome not in step.outcomes` check and before `patch = build_output_patch(...)`, add: ```python validate_payload_against_schema( step.resume_schema, resume_payload, f"interrupt resume for {step.id}", ) ``` - [ ] **Step 4: Run resume tests** Run: ```powershell uv run pytest tests/core/test_execution_results.py::test_interrupt_resume_payload_validates_before_state_mutation tests/authoring/test_demo_workflow.py::test_interrupt_then_resume_to_send_email tests/core/test_concurrent_foreach_interrupts.py -q -n0 ``` Expected: pass. - [ ] **Step 5: Commit** ```powershell git add src/wf_core/runtime/ops/interrupts.py tests/core/test_execution_results.py git commit -m "fix: validate interrupt resume payloads" ``` --- ### Task 6: Expose Contract Through Run Inspection **Files:** - Modify: `src/wf_api/runs.py` - Test: `tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py` - Test: `tests/wf_cli/test_run_deploy.py` - [ ] **Step 1: Add RPC inspection assertion** In `tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py`, update `_interrupt_plan()` so its interrupt node includes explicit schemas: ```python "request_schema": { "type": "object", "properties": {"message": {"type": "string"}}, "required": ["message"], "additionalProperties": False, }, "resume_schema": { "type": "object", "properties": {"approved": {"type": "boolean"}}, "required": ["approved"], "additionalProperties": False, }, ``` In `test_mcp_backed_rpc_resumes_interrupted_run_after_server_rebuild`, after `assert started["interrupt"]["payload"]["message"] == "approve after restart?"`, add: ```python assert started["interrupt"]["outcomes"] == ["submitted"] assert started["interrupt"]["typed"] is True assert started["interrupt"]["request_schema"]["required"] == ["message"] assert started["interrupt"]["resume_schema"]["required"] == ["approved"] ``` - [ ] **Step 2: Add CLI inspection assertion** In `tests/wf_cli/test_run_deploy.py`, update `_interrupt_artifact()` interrupt node with the same `request_schema` and a permissive but explicit `resume_schema`: ```python "request_schema": { "type": "object", "properties": {"message": {"type": "string"}}, "required": ["message"], "additionalProperties": False, }, "resume_schema": { "type": "object", "properties": {}, "additionalProperties": False, }, ``` In `test_wf_run_watch_stops_on_interrupted_run`, after the interrupt payload assertions, add: ```python assert payload["interrupt"]["typed"] is True assert payload["interrupt"]["request_schema"]["required"] == ["message"] ``` - [ ] **Step 3: Run tests and verify red if payload missing fields** Run: ```powershell uv run pytest tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py::test_mcp_backed_rpc_resumes_interrupted_run_after_server_rebuild tests/wf_cli/test_run_deploy.py::test_wf_run_watch_stops_on_interrupted_run -q -n0 ``` Expected: fail only if inspect payload omits the new contract fields. If it already passes because dataclass `asdict` includes fields, continue to Step 4 and keep the tests as regression coverage. - [ ] **Step 4: Keep `_interrupt_payload` JSON-safe and explicit** In `src/wf_api/runs.py`, keep `_interrupt_payload()` as the single public shape normalizer. Ensure it preserves: ```python payload = asdict(run.interrupt) ``` If the route-normalization block mutates route only, leave schema fields untouched. Add this comment above `payload = asdict(run.interrupt)`: ```python # The interrupt contract is copied into RunState at pause time so clients # can render/resume without reloading mutable workflow definitions. ``` - [ ] **Step 5: Run RPC/CLI tests** Run: ```powershell uv run pytest tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py::test_mcp_backed_rpc_resumes_interrupted_run_after_server_rebuild tests/wf_cli/test_run_deploy.py::test_wf_run_watch_stops_on_interrupted_run -q -n0 ``` Expected: pass. - [ ] **Step 6: Commit** ```powershell git add src/wf_api/runs.py tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py tests/wf_cli/test_run_deploy.py git commit -m "feat: expose interrupt contracts in run inspect" ``` --- ### Task 7: API/CLI Resume Validation Regression **Files:** - Test: `tests/wf_api/test_run_api.py` - Test: `tests/wf_cli/test_run_deploy.py` - Modify: `src/wf_cli/commands/runs.py` - [ ] **Step 1: Add API regression test for invalid resume payload** Add this test to `tests/wf_api/test_run_api.py`: ```python async def test_resume_run_rejects_payload_that_violates_interrupt_schema(tmp_path: Path) -> None: store = FileStore(tmp_path / "store") api = build_workflow_api_for_test(store) deployment_id = _seed_interrupt_deployment_with_resume_schema(store) started = await api.run_deployment( deployment_id=deployment_id, workflow_input={"message": "approve?"}, ) run_id = started["run_id"] assert run_id is not None resumed = await api.resume_run( run_id=run_id, resume_payload={"approved": "yes"}, resume_outcome="submitted", ) assert resumed["status"] == "failed" assert "interrupt resume for approval['approved']" in resumed["error"] ``` Use existing helper names in `tests/wf_api/test_run_api.py`. If the file does not have `build_workflow_api_for_test` or `FileStore`, mirror the existing store/API setup used by nearby run tests. Add a local helper: ```python def _seed_interrupt_deployment_with_resume_schema(store: FileStore) -> str: """Seed one deployment whose interrupt requires a boolean approval.""" artifact_store = FileWorkflowArtifactStore(store.root) deployment_store = FileWorkflowDeploymentStore(store.root) artifact_store.save_artifact(_interrupt_artifact_with_resume_schema()) deployment_store.save_deployment( WorkflowDeployment( id="approval.default", artifact_id="approval", version=1, source_bindings={}, ) ) return "approval.default" ``` Reuse existing artifact/deployment classes imported in that test file. - [ ] **Step 2: Add CLI help assertion** In `tests/wf_cli/test_app.py`, add: ```python def test_wf_run_resume_help_mentions_interrupt_schema_validation() -> None: result = runner.invoke(app, ["run", "resume", "--help"]) assert result.exit_code == 0 assert "resume payload" in result.stdout assert "schema" in result.stdout ``` - [ ] **Step 3: Run tests and verify red if help missing schema wording** Run: ```powershell uv run pytest tests/wf_cli/test_app.py::test_wf_run_resume_help_mentions_interrupt_schema_validation -q -n0 ``` Expected: fail until help/docstring mentions schema validation. - [ ] **Step 4: Update CLI help/docstring** In `src/wf_cli/commands/runs.py`, change the `--payload` help text: ```python typer.Option( "--payload", help="Resume payload JSON object; interrupted runs may validate it against their resume schema.", ) ``` Change `--payload-file` help text: ```python typer.Option( "--payload-file", help="Path to resume payload JSON object; schema validation happens before state mutation.", ) ``` Extend the `resume_run` docstring: ```python The interrupted run may expose a resume schema through `wf run inspect`. Invalid resume payloads are rejected before workflow state is mutated. ``` - [ ] **Step 5: Run focused tests** Run: ```powershell uv run pytest tests/wf_cli/test_app.py::test_wf_run_resume_help_mentions_interrupt_schema_validation tests/wf_api/test_run_api.py -q -n0 ``` Expected: pass. - [ ] **Step 6: Commit** ```powershell git add src/wf_cli/commands/runs.py tests/wf_cli/test_app.py tests/wf_api/test_run_api.py git commit -m "test: cover interrupt resume schema validation" ``` --- ### Task 8: Authoring Builder Convenience **Files:** - Modify: `src/wf_authoring/dsl/builder.py` - Test: `tests/authoring/test_builder.py` - [ ] **Step 1: Inspect current builder interrupt signature** Run: ```powershell rg -n 'def interrupt|InterruptNode' src\wf_authoring tests\authoring\test_builder.py ``` Expected: find the builder method that constructs `InterruptNode`. - [ ] **Step 2: Add failing builder test** Add this test to `tests/authoring/test_builder.py` near the existing interrupt builder tests: ```python def test_builder_interrupt_accepts_request_and_resume_schemas() -> None: builder = WorkflowBuilder( name="interrupt_contract", input_schema={"type": "object", "properties": {}}, state_schema={"fields": {}}, output_schema={"type": "object", "properties": {}}, ) interrupt = builder.interrupt( kind="approval", request_schema={ "type": "object", "properties": {"message": {"type": "string"}}, "required": ["message"], }, resume_schema={ "type": "object", "properties": {"approved": {"type": "boolean"}}, "required": ["approved"], }, ) assert interrupt.request_schema["required"] == ["message"] assert interrupt.resume_schema["required"] == ["approved"] assert interrupt.has_explicit_contract is True ``` - [ ] **Step 3: Run test and verify red** Run: ```powershell uv run pytest tests/authoring/test_builder.py::test_builder_interrupt_accepts_request_and_resume_schemas -q -n0 ``` Expected: fail because builder does not accept schema kwargs. - [ ] **Step 4: Add builder kwargs** In `src/wf_authoring/dsl/builder.py`, update the `interrupt(...)` method signature to accept: ```python request_schema: Mapping[str, Any] | None = None, resume_schema: Mapping[str, Any] | None = None, ``` When constructing `InterruptNode`, pass: ```python request_schema=dict(request_schema) if request_schema is not None else None, resume_schema=dict(resume_schema) if resume_schema is not None else None, ``` If passing `None` would serialize null into the model payload, build the payload dict and include only non-`None` schema keys: ```python payload: dict[str, Any] = { "id": node_id, "type": "interrupt", "kind": kind, "request": list(request or []), "resume": list(resume or []), "outcomes": list(outcomes or ["submitted"]), } if request_schema is not None: payload["request_schema"] = dict(request_schema) if resume_schema is not None: payload["resume_schema"] = dict(resume_schema) node = InterruptNode.model_validate(payload) ``` Use existing variable names from the method. - [ ] **Step 5: Run builder tests** Run: ```powershell uv run pytest tests/authoring/test_builder.py::test_builder_interrupt_accepts_request_and_resume_schemas tests/authoring/test_builder.py::test_builder_interrupt_accepts_canonical_request_and_resume_bindings -q -n0 ``` Expected: pass. - [ ] **Step 6: Commit** ```powershell git add src/wf_authoring/dsl/builder.py tests/authoring/test_builder.py git commit -m "feat: add interrupt schema builder helpers" ``` --- ### Task 9: Schema Discovery And Docs **Files:** - Modify: `docs/wf_cli.md` - Modify: `skills/wf-cli/SKILL.md` - Modify: `skills/wf-workflow/references/workflow-lifecycle.md` - Modify: `docs/current_roadmap.md` - Optional test: `tests/wf_cli/test_schema.py` - [ ] **Step 1: Verify `wf schema InterruptNode` includes fields** Run: ```powershell uv run wf schema InterruptNode --verbose ``` Expected: JSON Schema output includes `request_schema` and `resume_schema`. If it does not, inspect `src/wf_cli/schema_catalog.py` and add `InterruptNode` field projection coverage there. Add or update a test in `tests/wf_cli/test_schema.py`: ```python def test_wf_schema_interrupt_node_includes_interrupt_contract_fields() -> None: result = runner.invoke(app, ["schema", "InterruptNode", "--verbose"]) assert result.exit_code == 0 assert "request_schema" in result.stdout assert "resume_schema" in result.stdout ``` - [ ] **Step 2: Update CLI docs** In `docs/wf_cli.md`, add a short subsection under run inspect/resume docs: ```md ### Interrupt Resume Schemas Interrupted runs may include `interrupt.request_schema` and `interrupt.resume_schema` in `wf run inspect` output. The request schema describes the payload shown to the operator. The resume schema describes the payload accepted by `wf run resume --payload` or `--payload-file`. Resume payload validation happens before workflow state mutation. If validation fails, inspect the schema and retry with a payload that matches the declared shape. ``` - [ ] **Step 3: Update CLI skill** In `skills/wf-cli/SKILL.md`, add: ```md - For interrupted runs, call `wf run inspect ` before resuming. If the interrupt includes `resume_schema`, shape `wf run resume --payload` to that schema instead of guessing field names. ``` - [ ] **Step 4: Update workflow lifecycle skill reference** In `skills/wf-workflow/references/workflow-lifecycle.md`, add: ```md ## Interrupt Resume Contracts An interrupted run can carry a self-describing resume contract. Treat `interrupt.resume_schema` from `wf run inspect` as the source of truth for the payload you pass to `wf run resume`. Do not read workflow source code just to guess approval fields when the schema is present. ``` - [ ] **Step 5: Update roadmap** In `docs/current_roadmap.md`, under the active Workflow Console initiative, change implementation item 1 from future wording to completed wording only if the full implementation is done: ```md 1. Completed: self-describing interrupt request/resume schemas are carried through core execution, persisted run inspection, and resume validation. ``` Do not mark the web console itself completed. - [ ] **Step 6: Run docs/schema checks** Run: ```powershell uv run pytest tests/wf_cli/test_schema.py tests/docs -q -n0 ``` Expected: pass. - [ ] **Step 7: Commit** ```powershell git add docs/wf_cli.md skills/wf-cli/SKILL.md skills/wf-workflow/references/workflow-lifecycle.md docs/current_roadmap.md tests/wf_cli/test_schema.py src/wf_cli/schema_catalog.py git commit -m "docs: document interrupt resume contracts" ``` If `src/wf_cli/schema_catalog.py` and `tests/wf_cli/test_schema.py` were not needed, omit them from `git add`. --- ### Task 10: Final Regression **Files:** - No new files expected. - [ ] **Step 1: Run focused interrupt suites** Run: ```powershell uv run pytest tests/core/test_canonical_node_bindings.py tests/core/test_execution_results.py tests/core/test_run_codec.py tests/authoring/test_builder.py tests/wf_api/test_run_api.py tests/wf_cli/test_run_deploy.py tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py -q -n0 ``` Expected: pass. - [ ] **Step 2: Run lint and type checks** Run: ```powershell uv run ruff check uv run ruff format --check uv run basedpyright --level error ``` Expected: all clean. If `ruff format --check` fails, run: ```powershell uv run ruff format uv run ruff format --check ``` - [ ] **Step 3: Run a CLI smoke with an interrupting deployment** Run an existing CLI interrupt test as product smoke: ```powershell uv run pytest tests/wf_cli/test_run_deploy.py::test_wf_run_resume_interrupted_run -q -n0 ``` Expected: pass. - [ ] **Step 4: Search for stale docs** Run: ```powershell rg -n 'request_schema|resume_schema|InterruptNode|wf run resume|interrupt schema' docs skills src tests ``` Expected: new docs mention interrupt schemas; no docs claim resume payload shape must be guessed from source code. - [ ] **Step 5: Commit final polish if needed** If formatting/docs search caused changes: ```powershell git add . git commit -m "chore: polish interrupt contract implementation" ``` If no changes are needed, do not create an empty commit. --- ## Final Report Requirements The implementing agent must report: - files changed; - test commands and results; - whether legacy untyped interrupts still pass; - whether invalid resume payloads fail before state mutation; - whether `wf run inspect` exposes `request_schema`, `resume_schema`, `outcomes`, and `typed`; - any pre-existing failures outside this slice. ## Plan Self-Review Spec coverage: - core model fields: Task 1; - static schema validation: Task 2; - persisted interrupt payload contract: Task 3 and Task 6; - request validation: Task 4; - resume validation before mutation: Task 5 and Task 7; - builder convenience: Task 8; - CLI/schema/docs/skills: Task 9; - compatibility/defaults: Task 1 and Task 3; - no web console or demo-agent implementation: scoped out explicitly. Placeholder scan: - No task contains red-flag placeholder tokens or unspecified implementation work. - Each code-changing task has concrete code or exact behavior. Type consistency: - Public fields are consistently named `request_schema`, `resume_schema`, `outcomes`, and `typed`. - Legacy marker is internal-only as `has_explicit_contract` and is excluded from serialized workflow documents.