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

1218 lines
36 KiB
Markdown

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