Files
lda-wf/docs/historical/superpowers/plans/2026-07-01-self-describing-interrupt-contracts.md
T

36 KiB

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.

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:

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:

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:

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:

def _object_schema() -> dict[str, object]:
    """Default legacy interrupt contract: any JSON object payload is accepted."""
    return {"type": "object", "additionalProperties": True}

Update InterruptNode:

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:

        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:

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
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:

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:

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:

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:

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:

    @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:

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
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:

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:

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:

def _object_schema() -> dict[str, object]:
    """Default legacy interrupt contract used when older checkpoints are loaded."""
    return {"type": "object", "additionalProperties": True}

Update InterruptRequest:

@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:

    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:

uv run pytest tests/core/test_run_codec.py tests/core/test_execution_results.py -q -n0

Expected: pass.

  • Step 6: Commit
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:

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:

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:

from wf_core.runtime.ops.schemas import validate_payload_against_schema

After request bindings build payload and before return InterruptRequest(...), add:

    validate_payload_against_schema(
        node.request_schema,
        payload,
        f"interrupt request for {node.id}",
    )
  • Step 4: Run request-validation tests

Run:

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
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:

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:

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:

    validate_payload_against_schema(
        step.resume_schema,
        resume_payload,
        f"interrupt resume for {step.id}",
    )
  • Step 4: Run resume tests

Run:

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
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:

"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:

    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:

"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:

    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:

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:

    payload = asdict(run.interrupt)

If the route-normalization block mutates route only, leave schema fields untouched. Add this comment above payload = asdict(run.interrupt):

    # 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:

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
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:

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:

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:

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:

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:

typer.Option(
    "--payload",
    help="Resume payload JSON object; interrupted runs may validate it against their resume schema.",
)

Change --payload-file help text:

typer.Option(
    "--payload-file",
    help="Path to resume payload JSON object; schema validation happens before state mutation.",
)

Extend the resume_run docstring:

    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:

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
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:

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:

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:

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:

request_schema: Mapping[str, Any] | None = None,
resume_schema: Mapping[str, Any] | None = None,

When constructing InterruptNode, pass:

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:

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:

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
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:

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:

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:

### 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:

- For interrupted runs, call `wf run inspect <run_id>` 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:

## 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:

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:

uv run pytest tests/wf_cli/test_schema.py tests/docs -q -n0

Expected: pass.

  • Step 7: Commit
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:

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:

uv run ruff check
uv run ruff format --check
uv run basedpyright --level error

Expected: all clean. If ruff format --check fails, run:

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:

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:

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:

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.