
Stratum brings formal execution semantics to Claude Code by replacing improvised agent plans with typed YAML specs that declare contracts, postconditions, and human gates. The MCP server exposes tools like stratum_plan, stratum_step_done, and stratum_audit that validate schemas, enforce ensure clauses on every step, and track auditable flow state across sessions. When a postcondition fails, the agent gets the exact violation instead of a blind retry. Gates pause flows for human approval with approve, revise, and kill routing. You'd reach for this when you want governed, multi-round agent workflows with real checkpoints instead of hoping the LLM improvises correctly, especially when different models need to act as implementer and reviewer in the same flow.
State machine dispatch server for AI agent workflows.
Your agent proposes the step. Stratum decides whether it actually finished.
Stratum gives AI coding agents (Claude Code, Codex, etc.) a formal execution model. Instead of improvising a plan and retrying blindly, the agent writes a typed spec, the server tracks state, enforces postconditions, and returns structured failure context on retry. Every step produces an auditable trace record.
Where it sits. Stratum is the execution kernel, one layer below the thing most people run day to day. Compose drives the product lifecycle (design, blueprint, plan, review gates) and calls Stratum to execute each step. Reach for Stratum directly when you want the state machine and the postconditions without a lifecycle on top of them.
The founding intent behind this machinery is recorded in docs/VISION.md: a spec language that keeps LLMs on rails invisibly, so the same conversation yields stronger results than freeform execution.
One shipped component:
ts/ — the TypeScript engine (@smartmemory/stratum): IR validation (version: 1 specs), flow execution with ensure postconditions, MCP server for Claude Code, query/gate/guard CLI, background flows and background agent runs. Published to npm as @smartmemory/stratum (bins: stratum, stratum-mcp) and listed in the MCP registry as ai.smartmemory/stratum-mcp.Engine status (2026-07-18, STRAT-PY-RETIRE): the TS engine is the ONLY engine. The Python library (
stratum-py) and Python MCP server (stratum-mcp) are retired. Their source is archived on thepython-legacybranch, and PyPI packages are frozen at their final releases. The engine executesversion: 1specs exclusively. Legacy v0.x specs are rejected byvalidateand classified (report-only) bystratum migrate --check. The authoritative v1 shape is the Zod IR schema ints/src/ir/plusstratum validateoutput.
Governed workflows as auditable flows — on any agent, not just one vendor. Unlike a single-vendor in-context orchestrator, Stratum runs as an MCP server and a library under Claude Code, Codex, or any MCP host; enforces typed contracts and ensure postconditions on every flow execution; stops at real human gates; dispatches Claude and Codex agents in one flow (so an independent reviewer can be a different model from the implementer); and persists flow state across sessions. Where you want raw in-context fan-out, reach for an in-host workflow runtime; where you want the run governed, portable, and auditable, that's a Stratum workflow.
Install from npm (Node >= 22):
npm install -g @smartmemory/stratum # provides `stratum` (CLI) and `stratum-mcp` (MCP server)
Or run from a checkout for development:
git clone https://github.com/smartmemory/stratum
cd stratum/ts && npm install # or pnpm install
Requires node >= 22 (erasable-syntax type stripping; node >= 24 needs no flags — the CLI
bootstrap gates --experimental-transform-types automatically).
Register the server in your project's .mcp.json. From the npm package:
{
"mcpServers": {
"stratum": {
"command": "npx",
"args": ["-y", "-p", "@smartmemory/stratum", "stratum-mcp"]
}
}
}
From a checkout:
{
"mcpServers": {
"stratum": {
"command": "node",
"args": ["/absolute/path/to/stratum/ts/src/mcp/bin.mjs"]
}
}
}
Restart Claude Code to activate. Optionally append the Stratum execution model block to your CLAUDE.md.
stratum help # validate | migrate | query | gate | guard | watch (npm install)
node ts/src/cli/bin.mjs help # same, from a checkout
From a checkout, a thin wrapper script (e.g. ~/bin/stratum-ts) pointing at ts/src/cli/bin.mjs avoids a PATH collision with the installed stratum bin.
When Claude Code has Stratum installed, it uses it automatically for non-trivial tasks:
.stratum.yaml spec internally (never shown to you)stratum_plan to validate the spec and get the first stepstratum_step_done after each step -- the server checks postconditionsstratum_audit at the end for a full execution traceYou see plain English narration throughout. The spec, state management, and postcondition enforcement happen behind the scenes.
A specification is the authored, version-controlled .stratum.yaml document. It declares contracts and one or more flows. The flows.entry field selects the flow that starts a run.
A flow is an executable directed acyclic graph of steps. Running the entry flow creates a persisted run with a runId. The v0.x top-level workflow: registration block and stratum_list_workflows were retired with the Python server.
A flow declares typed input fields, a typed output, optional limits, and steps. References and after lists form data and ordering edges. Gate routing and on_fail add explicit routing edges.
A step has an id, optional after dependencies, an optional when condition, and exactly one construct: do, set, gate, fanout, or run.
A do step is an agent-dispatched task. The task text is declared inline, and ${...} references inject flow input or prior step output values. The v0.x functions: registry and function: steps have no place in a v1 document.
Contracts define named output shapes. A do or set step declares its output contract with out. A flow declares both the step-output reference that supplies its result and the contract used to validate that result.
Ensures are structured postconditions on do, set, and fanout stage results. V1 supports expression, file existence, file content, and judged predicates.
A do or fanout step can set the positive integer attempts limit. The default is two attempts. Contract failures, ensure failures, task failures, and exhausted iterations use the same failure path. A deterministic set failure terminates the flow without retrying.
Gate steps pause execution for an external approve, revise, or kill decision. Approve and kill routes may name a later step or use null. A revise route may name a strict ancestor and requires a flow-level max_rounds limit.
The Zod IR schema in ts/src/ir/ and the errors produced by stratum validate are authoritative. The root is strict and has exactly three fields: version, contracts, and flows. Unknown fields are rejected.
version: 1
contracts:
SentimentResult:
label: string
confidence: number
flows:
entry: classify
classify:
input:
text: string
output:
from: "${classify_text.output}"
contract: SentimentResult
steps:
- id: classify_text
do: "Classify the sentiment of ${input.text}"
agent: claude
out: SentimentResult
ensure:
- expr: "result.label != ''"
- expr: "result.confidence > 0.7"
attempts: 2
version: 1
contracts:
WorkOutput:
result: string
quality_score: number
flows:
entry: reviewed_work
reviewed_work:
input:
text: string
output:
from: "${work.output}"
contract: WorkOutput
max_rounds: 3
steps:
- id: work
do: "Produce the deliverable requested in ${input.text}"
agent: codex
out: WorkOutput
ensure:
- expr: "result.quality_score >= 0.8"
attempts: 3
- id: review
after: [work]
gate:
on_approve: null
on_revise: work
on_kill: null
max_rounds: 2
version (required)The only accepted value is the number 1. Quoted strings such as "1" and all v0.x values are rejected.
contracts (required)Each contract maps field names to type strings. Objects are strict at runtime, so undeclared output fields are rejected.
| Type form | Meaning |
|---|---|
string, integer, number, boolean | Scalar value |
object, array | Untyped JSON object or array |
string[], Result[] | Typed array |
| `draft | final` |
| `(draft | final)[]` |
Result | Another named contract |
string?, Result[]? | Optional field |
Named contract references use an initial capital letter. Recursive contract references and unknown contract names are rejected.
flows (required)flows.entry must name a flow in the same mapping. Every flow has these fields:
| Field | Required | Shape |
|---|---|---|
input | yes | Field-to-type mapping using the contract type language |
output | yes | {from: "${step_id.output}", contract: ContractName} |
steps | yes | Array of strict step objects |
budget | no | Positive limits for one or more of usd, tokens, dispatches, or ms |
max_rounds | no | Positive integer required when a gate can revise to an ancestor |
carry | no | Named loop-carried flow values; entry flow only |
The output.from value must be one full reference to a step output with a known contract. A fanout output is an array, so a flow output must select an item such as ${fan.output[0]}.
Step IDs start with a lowercase letter and may contain lowercase letters, digits, underscores, and hyphens. IDs are unique within a flow. Every step accepts id, optional after, and optional when, then exactly one construct with only the fields listed below.
| Construct | Purpose | Additional fields |
|---|---|---|
do | Dispatch an inline task | agent, out, ensure, attempts, iterate, budget, on_fail |
set | Build an output object from expressions | required out, optional ensure |
gate | Pause for a decision | no fields outside the nested gate object |
fanout | Run stages over an array | attempts, budget, on_fail |
run | Invoke a subflow | required with, optional budget, on_fail |
agent is either claude or codex. attempts is a positive integer. An iterate object requires positive integer max and string expression until.
The nested gate object requires nullable on_approve, on_revise, and on_kill fields. It may also set a positive integer max_rounds.
The nested fanout object requires over, one or more steps, positive integer concurrency, isolation of worktree or none, require of all, any, or a positive integer, and merge: sequential. Optional fields are pre_merge and dispatch, whose value is engine or consumer. Each fanout stage requires do and may use agent, out, ensure, attempts, and when.
V1 uses ${...} references in task templates, subflow inputs, fanout sources, and flow outputs.
| Pattern | Resolves to |
|---|---|
${input.field} | Flow input field |
${step_id.output} | Full output of a prior step |
${step_id.output.field} | Field in a prior step output |
${fan.output[0].field} | Field in one fanout item output |
${item} | Current item inside a fanout stage task |
${prev} | Previous stage output for the same fanout item |
${name} | A declared carry variable |
${name.field} | Field inside a carry variable |
A full-value reference preserves its JSON type in with and fanout.over. A reference embedded in other text is interpolated into a string. Step-output references create data dependencies. Use after for ordering dependencies that are not implied by references.
Expressions in ensure, when, set, and iterate.until use the v1 expression bindings instead of ${...} interpolation. input is the flow input. In an ensure, result is the output under test. In when and set, result is a mapping of completed step IDs to outputs. Fanout stage expressions also receive item and prev.
The entry flow may declare a carry: block: a mapping from carry variable name to a shape with
one required initial reference and an optional on_revise mapping.
carry:
wave:
initial: "${plan.output.tasks}"
on_revise:
assess_gate: "${assess.output.tasks}"
initial and every on_revise value must be one full ${...} reference, exactly like a
step-output reference — no interpolation, no expression syntax. The initial source must be an
unconditional, non-gate step that is not the target of any routing edge (on_fail, on_approve,
or on_kill). Materialisation happens once per source epoch: the value is written the moment that
source step succeeds, and is written again if a revise resets the source and it succeeds anew.
Each on_revise key names a gate step id. When that gate resolves with revise, the engine
resolves the declared reference against the run's pre-reset scope and writes the result as the
variable's new value, alongside provenance (which gate, its consumed token, the source epoch, and
the round). A gate that declares nothing for a variable leaves it unchanged, so a merge-retry
revise re-fans over the same list.
A carry reference (${name} or ${name.field}) is legal wherever a step-output reference is
legal on a rendered field — do, with, evaluate.in, fanout.over, and a fanout stage's
do — but is rejected in the six expression fields: when, set, a fanout stage's when,
iterate.until, and ensure (both flow-level and stage-level). Carry values do not participate
in the v1 expression bindings.
A carry path may not begin with the exact segment output — ${wave.output} collides with the
step-output grammar and is rejected, while ${wave.outputs} (or any other field name) is fine.
Carry creates no dependency edge. A step that reads ${wave} needs an explicit after to
order it after the variable's initial source, and a gate that declares an on_revise for a
variable must have a revise target whose reset closure covers every step that reads that
variable. The gate itself must be ordered after those steps through after or step-output
dependencies — a routing edge into the gate does not count as ordering.
Carry is entry-flow only — a subflow's spec may not declare carry, and a carry variable is not
visible inside a subflow. Carry values are snapshotted with checkpoints (stratum_commit /
stratum_revert restore them alongside step outputs) and are exposed by stratum_audit.
stratum migrate --check <old.yaml> is a report-only classifier. It does not emit translated YAML.
| V0.x construct | V1 construct |
|---|---|
functions with infer or compute, plus function steps | Inline do task. Keep the step's agent when present, and do not translate compute to set |
| Gate function plus routing fields | Nested gate object |
Inline intent step | do task |
| Deterministic or judged predicates with one antecedent | Ensures on that antecedent |
decompose without cross-item dependencies | Task contract with tasks: T[], followed by fanout |
depends_on | Data references plus after for remaining ordering edges |
skip_if | when with the condition inverted |
flow step plus inputs | run plus with |
max_iterations plus exit_criterion | iterate: {max, until} |
parallel_dispatch | Re-authored fanout |
Pipeline stage when | Fanout stage when |
Flow max_rounds | Flow max_rounds |
Reducible next routing | DAG edges and gate routing |
on_fail | on_fail targeting a topologically later step |
| Legacy ensure expressions | Re-authored v1 expressions |
output_schema or output_contract | Named v1 contract language |
| Route-dependent flow output | One static output: {from, contract} producer |
Some legacy constructs have no v1 equivalent. These include verified or applied-gate judge predicates, judge budgets, score and accumulator fields, cross-item decompose dependencies, branch or manual parallel merge modes, certificate and timeout fields, nested pipeline regions, pipeline exit_when, gate policies, gate timeouts, and the top-level workflow block. Flatten or re-author supported regions. Unsupported regions must be redesigned before they can run on v1.
All tools are exposed via the MCP protocol. Claude Code calls them as tool invocations.
stratum_validateValidate a .stratum.yaml spec without creating a flow.
Inputs: spec (str, inline YAML)
Returns: {valid: bool, errors: list}
stratum_planValidate a spec, create execution state, and return the first step to execute.
Inputs:
spec (str) -- inline YAMLflow (str) -- flow nameinputs (dict) -- flow-level inputsReturns: Step dispatch object with status: "execute_step" or status: "await_gate", including:
flow_id -- unique identifier for this executionstep_id, step_number, total_stepsfunction, intent, inputs (resolved)output_contract, output_fields, ensureretries_remainingagent, step_modestratum_step_doneReport a completed step result. The server validates the result against output schemas and ensure expressions.
Inputs:
flow_id (str)step_id (str)result (dict) -- step output matching the output contractReturns one of:
status: "execute_step")status: "ensure_failed", violations, retries_remaining)status: "schema_failed", violations)status: "complete", output, trace, total_duration_ms)status: "error", error_type: "retries_exhausted")routed_from, violations)stratum_auditReturn the full execution trace for a flow.
Inputs: flow_id (str)
Returns:
flow_id, flow_name, status (complete, in_progress, killed)steps_completed, total_stepstrace -- array of step records (step_id, function_name, attempts, duration_ms, type, round)round, rounds -- round history for gate revise cyclesiterations, archived_iterations -- iteration historychild_audits -- audit snapshots from sub-flow executionstotal_duration_msstratum_gate_resolveResolve a gate step with a human/agent/system decision.
Inputs:
flow_id (str)step_id (str) -- must be the current gate stepoutcome (str) -- "approve", "revise", or "kill"rationale (str) -- human-readable reasonresolved_by (str) -- "human", "agent", or "system"Returns: Next step, flow completion, or flow termination.
stratum_commitSave a named checkpoint of the current flow state.
Inputs: flow_id (str), label (str)
stratum_revertRoll back flow state to a previously committed checkpoint.
Inputs: flow_id (str), label (str)
stratum_compile_speckitCompile a spec-kit tasks directory into a .stratum.yaml flow.
Inputs: tasks_dir (str), flow_name (str, default "tasks")
Returns: {status, yaml, flow_name, steps} on success.
stratum_resumeRehydrate a persisted flow into this server process (live-process reparenting) so a fresh session can continue a run it did not start.
stratum_flow_run_bg / stratum_flow_poll / stratum_flow_bg_poll / stratum_flow_cancel_bgDetached background flow execution: start a flow under a background driver, poll its progress, cancel it. Survives the launching session.
stratum_flow_cancelCancel a running foreground flow by flow id. Unlike stratum_flow_cancel_bg, which abandons a background run, this settles the run to the terminal status cancelled under the per-run file lock, burns every outstanding step, gate and fanout-item issuance, and then terminates the foreground agents Stratum spawned for that flow.
Which process may issue it. The lock makes the call safe from any process, but a run whose in-memory object is PINNED by a live driver can only be settled by that driver. A pin happens whenever a process is actively working the run: a background driver loop, a gate re-kick, or an engine-dispatch consumer fanout. The pinning process declares itself in a driver lease beside the run record, and a cancel from anywhere else is refused with CANCELLATION_UNCONFIRMED and reason: "engine_dispatch_active", carrying the holder's pid, rather than being allowed to race the object that process is mutating. A lease whose owner is provably dead is reclaimed and the cancel proceeds. An unpinned run, which is the common case for a foreground consumer fanout driven by an external agent, is cancellable from any process at all. That boundary is the point of the split: a consumer fanout's agents are reachable through the registry from anywhere, a live driver's in-memory run is not.
Inputs: runId (str)
Returns: {runId, status, flowSettled, acknowledged, reason?, ledger, agents}. flowSettled says the run is durably cancelled. acknowledged additionally says every agent group claimed for the flow reached a final resolved state, either torn down by this call (reaped) or already gone when we first probed it (gone), and that no entry is left unsettled, unresolved or unreachable.
The durable status is read first, so a run that is already terminal is never mutated and never refused: a finished run returns flowSettled: false with reason: "already_<status>", and an already-cancelled one returns flowSettled: true with reason: "already_cancelled" and still sweeps its agents. That second case is the documented recovery from a teardown timeout, and it works even while the original driver's lease is still live.
An unconfirmed teardown is an error, not a caveat: CANCELLATION_TEARDOWN_TIMEOUT (a group was signalled and outlived the deadline) or CANCELLATION_UNCONFIRMED (an entry is unreachable, the run lock was held, reported as reason: "run_lock_held" with holderPid, or a live driver holds the run, reported as reason: "engine_dispatch_active"). Errors raised after the settle carry the engine's real status and flowSettled plus the partial agents summary. Errors raised instead of a settle carry flowSettled: false, an all-zero agents summary because no teardown was attempted, and status: "running" as the caller's own assumption rather than a reading of the record, since nothing was loaded. That is what lets a caller tell "the flow is stopped but one group survived" from "we never even took the lock".
Cancellation is split by ownership: foreground cancel settles the flow and kills the agents Stratum spawned, background cancel abandons the run, and a consumer's own in-process agents remain the consumer's to abort, because Stratum cannot reach them.
stratum_agent_run / stratum_agent_poll / stratum_cancel_agent_runDispatch Claude, Codex, or Devin synchronously or in the background. Background runs return a durable runId for polling and cancellation. Devin is also usable in flows with agent: devin on steps and fan-out stages. Engine-dispatched worktree stages give Devin write access to their worktree; extra write roots come from the worktree’s committed stratum.toml [sandbox]. dispatch: consumer fan-outs still reject Devin in Compose until COMP-AGENT-DEVIN-1 adds support.
Devin background runs use the same per-run home and macOS sandbox as foreground runs. Poll returns live narration from stdout.log, then the final answer and token usage from the ATIF export (usd: 0, estimated for SWE-2). In v1, stratum watch shows no Devin narration until the run ends, and a running Devin peer's updatedAt remains its registration time.
MCP background responses include completionInstructions with the actual run ID and, when available, peer name. Follow these immediately: subscribe with Claude Code's SendMessage using notify_when_idle: true, then call stratum_agent_poll after the idle notice to fetch the report. Launching or polling alone does not subscribe the caller. If the peer or SendMessage is unavailable, keep polling while the run is running. The MCP tool descriptions also expose this workflow so callers do not need to discover it in this README.
Why this subscribe step exists, and why Stratum cannot remove it: MCP's spec supports server-initiated progress notifications (progressToken), and Claude Code does send that token on tool calls — but Claude Code does not currently wire that into its visible task-notification system, which requires client-side support for the MCP "Tasks" mode (SEP-1686). That support is not shipped as of Claude Code 2.1.272 (tracked upstream: anthropics/claude-code#18617, #52137). Verified 2026-09-19: this is a Claude Code harness limitation, not something an MCP server can work around by changing how it uses the protocol. Bash run_in_background gets automatic notifications because it is a Claude Code-native mechanism, unrelated to MCP. Re-check this note if a future Claude Code version ships SEP-1686 — the subscribe/poll workflow above would then become unnecessary.
Alternative for pure Codex dispatch (no Stratum flow/checkpoint features needed): running codex exec directly inside a Bash call with run_in_background: true DOES get an automatic Claude Code notification, with no subscribe step, because it goes through the native Bash-backgrounding path instead of MCP. This only substitutes for the plain agent-dispatch part of stratum_agent_run — it has no equivalent for flow-attached runs, ledger/budget tracking, or peer registration. Recipe: codex exec -m <model> -c model_reasoning_effort='"<effort>"' -s <read-only|workspace-write|danger-full-access> [-c sandbox_workspace_write.network_access=true] "<prompt>" < /dev/null (the < /dev/null is required — see the stdin-stall note in the Codex connector section). A bare codex exec with no -s flag defaults to danger-full-access; always pass it explicitly.
Codex, Claude, and Devin background runs register a Claude Code peer row (codex-<model>-<runId6> · bg · busy claude-<model>-<runId12> · bg · busy, or devin-<effort>-<runId6> · bg · busy) when the session registry is available. The response includes peerName when registration is attempted; poll reports peer.registered: true once registration succeeds. Subscribe with SendMessage(to=peerName, notify_when_idle=true) to receive an idle notice when the run finishes. If the peer cannot be found, fall back to stratum_agent_poll. The idle row remains for 15 seconds before cleanup; peer.registered records successful registration, not current socket availability. Under the default exec strategy, inbound user messages are refused because the run's prompt is fixed at spawn. Background Codex runs can accept them under the app-server strategy described below. Set STRATUM_PEER_REGISTER=0 to disable registration, or STRATUM_PEER_LINGER_MS to change retention. STRATUM_PEER_SESSIONS_DIR and STRATUM_PEER_SOCK_DIR override the registry and socket directories; the registry otherwise follows CLAUDE_CONFIG_DIR (default ~/.claude) and sockets default to /tmp/cc-socks. This integration follows Claude Code 2.1.272's peer protocol; a live record advertising a newer protocol disables registration without affecting the run.
Messaging a running Codex background run (STRATUM_CODEX_BG_STRATEGY=app-server, opt-in). By default a background Codex run uses codex exec, which has no input channel after launch. Set STRATUM_CODEX_BG_STRATEGY=app-server in the MCP server's environment to run background Codex turns through codex app-server instead. A SendMessage to the run's peer name then reaches the active turn as a steer (turn/steer), and the model sees it within that same turn. Foreground runs and STRATUM_CODEX_TRANSPORT are unaffected, and an injected command always uses exec.
delivered only after Codex accepts the steer for the active turn. A message the server refuses, or one whose outcome cannot be known, is reported as dropped or unknown and never retried. A message to a run that has already finished is refused (expired). A message never ends the run, even when it fails.notify_when_idle subscriptions work exactly as they do for exec runs, and exactly one idle notice arrives when the run finishes.exec. Sandbox mode, writable roots and network access are passed the same way exec passes them, so settings in your Codex config (for example temp-directory exclusions) apply identically. The strategy never grants more access than exec would. approvalPolicy: "on-failure" has no app-server equivalent, so that combination is rejected before anything starts. Nobody is present to answer approval prompts, so the run declines them and continues.stratum_cancel_agent_run stops the driver and app-server together. If the driver is killed, the poll reports an error and the app-server exits when its input closes. Tool processes that start their own process group are outside this cleanup, the same limitation exec has.exec strategy if you run another Codex version.Each Claude background worker (a thread inside the MCP server process, not a separate OS process) gets its own detached peer sidecar, the same one-process-per-run shape Codex uses — a worker thread has no pid of its own to register, so the sidecar supplies one. The sidecar learns the worker finished through a private IPC channel rather than watching a child pid; a worker that ends while the MCP server is still alive reports idle normally, and losing the MCP server itself (not just one worker) surfaces as unavailable rather than a false idle. Claude workers do not survive an MCP server restart — there is no detached process to reattach to, unlike Codex's independently-running child.
Pass an optional peerLabel on stratum_agent_run (background-only, either agent — foreground calls reject it) to get a readable name instead of a bare run ID: peerLabel: "schema review" on a Claude run named claude-sonnet-5-5-<runId12> yields claude-sonnet-5-5-<runId12>-schema-review. The label is normalized (lowercased, non-alphanumeric runs collapsed to -) and is a display hint only, not an address — always subscribe using the peerName the response returns, not the label you passed in.
A foreground agent run may declare flow: {runId, stepId?, itemIndex?} alongside its cancellationId. That gives the run a durable record carrying its child pid and process start time, which is what lets stratum_flow_cancel terminate its process group from a different process. flow without a cancellationId is rejected: without an owned process group there is nothing to cancel.
For acknowledged foreground cancellation, supply a fresh UUID as cancellationId on stratum_agent_run, then call stratum_cancel_agent_run with that UUID as runId. The cancellation response waits for connector teardown. Keep awaiting the original run response as well; cancellation reports an error there. MCP transport cancellation and disconnection also abort foreground work, but MCP cancellation notifications suppress the original response and do not acknowledge teardown. Foreground IDs are scoped to the current server process; recent terminal IDs return already_complete, already_error, or cancelled.
allowedTools, disallowedTools, and thinking are Claude-specific. effort accepts Claude's low, medium, high, xhigh, max, or Codex's minimal, low, medium, high, xhigh. Unsupported provider settings fail before execution. Codex uses sandboxMode (read-only or workspace-write); an explicit effort must agree with any model suffix. Cancellable Codex runs use an owned process group around the Codex CLI so shell descendants stop before cancellation is acknowledged; the CLI on PATH is preferred and the SDK's bundled CLI is the fallback. Claude uses the SDK's custom spawn hook for the same process ownership. Process-group ownership is claimed only when a cancellationId is supplied, so ordinary runs are unaffected by it. It requires POSIX process groups: on Windows a run that asks for cancellation fails before spawn with CANCELLATION_UNSUPPORTED_PLATFORM, and non-cancellable runs there are unaffected. Detached processes that intentionally leave the agent's process group are outside this cancellation contract.
The authenticated live Codex smoke test is opt-in: STRATUM_LIVE_CODEX=1 npm test -- tests/connectors/codex.live.test.ts from ts/. Ordinary test runs do not invoke a paid model.
The authoritative tool surface is
ts/src/mcp/server.ts. Python-era tools that were retired rather than ported (parallel lifecycle, iterations, timers/skip,list_workflows,draft_pipeline, judge/goal surfaces) live onpython-legacy; see the Phase 2/3 usage audit indocs/plans/2026-07-11-strat-py-retire-progress.mdfor per-tool dispositions. The distill staging pipeline below is a TS port (STRAT-DISTILL-TS-1), not one of the retired surfaces — only its apply/write path remains unported.
stratum_distillDetects recurring workflows in Claude Code transcripts under a workspace and stages draft skill/subagent/command candidates — it never writes into skills/, agents/, or commands/ directly. Required: workspace_root. Optional: project_dir (defaults to the current transcript project directory), min_count, window_days, and write (default false — when false the tool only reports what it would stage; set write: true to actually append candidates to the sidecar). There is no MCP equivalent of the CLI's --all/--projects-root sweep; the MCP tool always targets one project directory per call.
Every candidate is a complete, human-reviewable draft: full rendered SKILL.md/agent/*.md/command/*.md content, the observed workflow (single tool or tool sequence) it's built from, resolvable evidence (transcript occurrences with source handles), and a deterministic revisionId — identical inputs always produce the identical candidate, and new evidence produces a new revision rather than mutating an old one. Candidates are staged to an independent .stratum/distill/ sidecar (schema distill-2.0, separate from the learn-1.0 MEMORY-patch sidecar) and are never auto-installed: nothing reads or applies a staged candidate without a human decision, and there is currently no gate that admits one into skills//agents//commands/ at all — that's future work (STRAT-DISTILL-APPLY, STRAT-ADMIT), not something this tool does.
Recurrence requires at least min_count (default 2) occurrences across at least 2 distinct sessions — a workflow used once, even repeatedly within one session, never stages. This mirrors the MEMORY-patch harvester's (ts/src/learn/) conservatism by design.
CLI equivalent: stratum distill <extract|top|stats> [--root <path>] [--project <path>] [--all --projects-root <path>] [--min-count N] [--window-days N] [--json]. extract is the only subcommand that stages candidates (always writes; there's no separate --write flag); top [--n N] inspects the top workflows by recurrence without staging anything, and stats reports singleton/sequence counts — both leave applied: false. --root is where candidates get staged (your repo); --project is where Claude Code transcripts are read from — these are different directories and are not interchangeable. --all --projects-root <path> sweeps every nested project directory under a root in one pass instead of one --project at a time.
Authorization model. Three policy-change capabilities of increasing power:
stratum_guard_upgradeneeds no authorization because it is provably non-weakening;stratum_guard_apply_upgradeapplies a signed, pre-reviewed descriptor;stratum_guard_migratedoes anything, under a signed one-shot authorization. Plusstratum_guard_override, which bypasses predicate verification on one legal edge, also under a signed one-shot authorization. All signing keys are enrolled incontracts/guard-signers.allowed, read from the installed source tree and never from the environment. It ships empty: there is no default trust.
stratum_judge/stratum_gate_resolve enforce guarantees only inside a flow. STRAT-GUARD exposes the same independent-verification engine (run_judge) as a standalone, resource-agnostic, tamper-evident state machine for clients that manage a resource lifecycle outside a stratum flow (e.g. compose's feature tracker). A client registers a transition graph with per-edge evidence predicates; stratum then permits a transition only if the edge is legal and its predicates verify against trusted, server-read evidence (not caller-staged claims). Every attempt — applied or refused — is appended to a hash-chained ledger. See docs/features/STRAT-GUARD/.
stratum_guard_registerRegister a guarded resource. The (graph, edge_predicates, terminal, stakes) policy is checksummed and immutable — re-registering an identical policy is a no-op; a different policy is rejected (use stratum_guard_upgrade for an additive change, stratum_guard_migrate for anything else).
Inputs: resource_id (str, client-namespaced e.g. "compose:FEAT-1"), graph (dict[from -> list[to]]), edge_predicates (dict["from->to" -> list of {id,type,statement}]), initial (str), terminal (list[str]), stakes (dict["from->to" -> "cheap"|"default"|"paranoid"]), workspace_root (abs dir, for file/git/command evidence).
Predicate type: "deterministic" → server-side trusted evidence (server_file_exists/git_commit_exists/command_exit_zero/verdict_receipt_clean); "verified"/"judged" → LLM-tier, routed through run_judge. A paranoid edge must declare ≥1 trusted predicate.
Returns: {guard_id, checksum, status}.
stratum_guard_transitionAttempt from_state -> to_state. Trusted predicates are verified server-side; any LLM-tier predicates go through the judge at the edge's stakes. Optimistic concurrency: evaluation runs outside a per-resource lock, the commit re-validates state under it.
Inputs: resource_id, from_state, to_state, artifacts (dict[str,str]), modified_files (list[str]), idempotency_key (str|None), resolved_by (str).
Returns: {status: applied|refused|replayed, verdict: JudgeResult-dict, ledger_ref, current_state}. ledger_ref is the receipt token presented to verdict_receipt_clean.
stratum_guard_overrideThe single sanctioned bypass of predicate verification. Requires a signed authorization, a human resolver, and a rationale. Moves a legal edge without verifying predicates and records a deviation ledger entry naming the signer. Replaces a force flag.
The signature covers {action, resource_id, from_state, to_state, rationale, ledger_head} as canonical JSON, under namespace stratum-guard-override. The server rebuilds that payload itself, so nothing but the signature travels — and because it includes the resource's current ledger head, an authorization is valid at exactly one point in that resource's history. Spending it moves the head and kills the signature, so there is nothing to replay and no expiry to tune.
stratum guard authorize prints the exact payload to sign:
echo '{"kind":"override","resource_id":"…","from_state":"…","to_state":"…","rationale":"…"}' \
| stratum guard authorize
# then: ssh-keygen -Y sign -f <key> -n stratum-guard-override <payload file>
Replaced
STRATUM_GUARD_OVERRIDE_TOKEN(2026-08-17). The token was compared against the environment of whatever process was running, so over the CLI a caller set both sides of the comparison and they matched — verified empirically against a guard whose predicate could never be satisfied. A signature has no such dependence on which process asks. Seedocs/features/STRAT-GUARD-AUTHZ/design.md.
Inputs: resource_id, from_state, to_state, authorization, rationale, resolved_by ("human"). Returns: adds authorized_by.
stratum_guard_migrateEvolve a registered policy arbitrarily — the emergency path. Requires a signed authorization whose payload names the resulting policy checksum, so it cannot be spent on a different migration: {action, resource_id, policy_checksum, rationale, ledger_head} under namespace stratum-guard-migrate. Bumps graph_version, writes a graph_version ledger entry naming the signer, and never silently relaxes an in-flight resource's policy. The current state must remain a node in the new graph.
Deliberately not retired in favour of signed descriptors: a mechanism that requires a reviewed artifact cannot be what you reach for when the reviewed artifact is what is broken.
Inputs: resource_id, new_graph, new_edge_predicates, authorization, rationale, new_terminal, new_stakes. Returns: adds authorized_by.
stratum_guard_upgradeRoutine policy evolution, with no override token. Idempotent: a policy whose checksum already matches returns unchanged and writes nothing — no ledger entry, no graph_version bump — so a lazy per-resource migration is safe to re-run over hundreds of guards. Any other change must be additive-only: no node or edge removed, existing edges byte-identical in predicates and stakes, new edges may only terminate at states that did not exist before, and terminal frozen exactly as registered with no new edge entering or leaving a terminal state. It can therefore neither grant a new way to be complete nor walk a completed resource back out — those stay authorization decisions on stratum_guard_migrate. Anything else is refused with incompatible_policy_upgrade. Ledger entries are the same graph_version kind migrate writes, distinguished by resolved_by: "agent".
Inputs: resource_id, new_graph, new_edge_predicates, rationale, new_terminal, new_stakes. Returns: {status: "migrated"|"unchanged", checksum, graph_version, ledger_ref?, rationale}.
stratum_guard_apply_upgradeApply a server-owned upgrade descriptor: a policy change a human reviewed and installed, named by id. No override token. This is the middle of three capabilities — it can do what stratum_guard_upgrade refuses (grant a terminal state, remove an edge, retighten predicates) because the exact resulting policy was authorized in advance, not supplied by the caller.
Authorization is a signature, not a value in the environment. The descriptor file must carry a detached sshsig at <path>.sig under the namespace stratum-guard-descriptors, from a key enrolled in contracts/guard-signers.allowed — which is read from the installed source tree, never from an env var. STRATUM_GUARD_UPGRADE_DESCRIPTORS still names the file's path, because locating an artifact is not authorizing it.
Keep the private half protected by a passphrase that exists only in your head, and never ssh-add it — anything that can reach your agent socket could then use it without knowing the passphrase. Sign with:
ssh-keygen -Y sign -f ~/.stratum/guard-signing -n stratum-guard-descriptors /path/to/guard-upgrades.json
contracts/guard-signers.allowed ships empty: there is no default trust, and an empty or missing trust root makes these paths report themselves unavailable rather than degrading. Verification is native (node:crypto), not a shell-out to ssh-keygen — that binary is resolved through PATH, and an authorization decision must not be delegated to something the caller can shadow. Run stratum guard descriptors to see what is installed, who signed it, and whether it verifies.
Each descriptor is {id, rationale, from_checksum, to_policy} and applies only to a resource whose current policy checksum is exactly from_checksum — authorization is for a transition between two named policies, not a destination in the abstract. Idempotent: a resource already at the target answers unchanged and writes nothing, so a fleet-wide batch is safe to re-run. The ledger entry names the descriptor id and the file digest.
MCP only, deliberately — there is no stratum guard apply-upgrade CLI action. A CLI process inherits the caller's environment, so a caller could point both variables at a descriptor file it wrote itself and mint its own authorization. The privileged apply runs only inside the server that owns the pinned environment. (The read-only stratum guard descriptors inspection stays on the CLI: it grants nothing.)
Inputs: resource_id, descriptor_id. Returns: {status: "applied"|"unchanged", checksum, graph_version, ledger_ref?, descriptor_id}.
stratum_guard_historyReturn a resource's current state and its append-only, hash-chained transition/deviation ledger (the tamper-evident audit trail).
Inputs: resource_id. Returns: {resource_id, current_state, graph_version, ledger: [...]}.
V1 has five mutually exclusive step constructs. This example includes all five:
version: 1
contracts:
Analysis:
summary: string
score: number
ItemResult:
value: string
Final:
message: string
flows:
entry: main
main:
input:
topic: string
items: string[]
output:
from: "${finish.output}"
contract: Final
steps:
- id: analyze
do: "Analyze ${input.topic}"
agent: claude
out: Analysis
- id: normalize
after: [analyze]
set:
summary: "result.analyze.summary"
score: "result.analyze.score"
out: Analysis
ensure:
- expr: "result.score >= 0"
- id: review
after: [normalize]
gate:
on_approve: null
on_revise: null
on_kill: null
- id: inspect
after: [review]
fanout:
over: "${input.items}"
concurrency: 2
isolation: none
require: all
merge: sequential
steps:
- do: "Inspect ${item}"
agent: codex
out: ItemResult
- id: child
after: [inspect]
run: summarize
with:
topic: "${input.topic}"
- id: finish
after: [child]
do: "Finalize ${child.output.message}"
out: Final
summarize:
input:
topic: string
output:
from: "${write_summary.output}"
contract: Final
steps:
- id: write_summary
do: "Summarize ${input.topic}"
out: Final
do Tasksdo contains the task text sent to an agent. agent defaults to claude. out names the result contract. A task may also declare ensure, attempts, iterate, budget, and on_fail.
set Stepsset evaluates each field expression without dispatching an agent. The resulting object is validated against the required out contract, then checked against any ensures. Set steps are deterministic and are not retried.
gate Stepsgate pauses the run until an external decision arrives. Its three route fields are required, although any of them may be null. Gate steps cannot carry task, retry, budget, or ensure fields.
fanout Stepsfanout.over must be one full reference that resolves to an array. Each item moves through the declared stages. ${item} is the source item and ${prev} is the previous stage output. The final stage needs out when later steps or the flow output reference the fanout result.
isolation: worktree gives each item a Git worktree. merge only accepts sequential. require decides how many item successes are needed before merge. dispatch defaults to engine. Consumer-dispatched worktree fanout has extra validation rules, including a required unconditional successor gate.
run Stepsrun names another flow in the same spec. with is required and its keys must exactly match the callee input keys. A run step's output contract is inherited from the callee. Non-entry flows cannot contain run or fanout, and recursive calls are rejected.
Every step must have exactly one of do, set, gate, fanout, or run. Fields from one construct cannot be mixed into another.
An ensure array contains strict predicate objects. Predicates run in order, and the first failure stops evaluation.
| Predicate | Shape |
|---|---|
| Expression | {expr: "result.confidence > 0.7"} |
| File exists | {file_exists: "build/report.json"} |
| File contains text | {file_contains: {path: "build/report.json", text: "passed"}} |
| Judged claim | {judged: {statement: "result is accurate", stakes: "default"}} |
Judged stakes are cheap, default, or paranoid. A judged predicate fails closed if no judge runner is configured. File predicates require a workspace root and are jailed to that root.
The v1 evaluator is a restricted expression language, not Python. It accepts JSON literals, member access, non-negative literal array indexes, function calls, parentheses, unary ! and -, arithmetic, comparisons, in, &&, and ||.
In an ensure, result is the step output and input is the flow input. Fanout stage ensures also receive item and prev.
| Function | Signature | Description |
|---|---|---|
len | len(value) | Length of a string, array, or object |
any, all | any(array), all(array) | Truth test across an array |
max, min | max(array), min(array) | Largest or smallest number or string |
str, int, bool | One argument | Restricted conversions |
matches | matches(text, pattern) | Bounded regular expression match |
file_exists | file_exists(path) | Check a file inside the workspace root |
file_contains | file_contains(path, text) | Check file content inside the workspace root |
result, input, item, and prev can be bound.__proto__, constructor, and prototype member access is blocked.An ensure failure records a structured failure reason. A do task is dispatched again while its attempts budget remains. Identical output evidence stops retries early because the same deterministic predicate cannot change its verdict.
Contracts use the field-to-type string language in the YAML reference. Task output is validated against out before ensures run. Flow output is validated again against output.contract when the referenced producer completes.
out is required on set. A do step may omit out only when no later reference or flow output needs a contract for that result.
V1 does not accept per-step output_schema or output_contract. Move supported fields into a named contract and reference it with out. The v1 contract language supports the types listed above, but it is not arbitrary JSON Schema.
Gates are approval checkpoints that pause flow execution until resolved externally.
Declare the gate directly on a step, as shown in the full gate example. There is no gate function declaration in v1.
on_approve, on_revise, and on_kill are all required and nullable.max_rounds.max_rounds may impose a tighter positive limit on that gate.gate object.on_revise for a carry variable rewrites that variable
before the reset. A gate that declares nothing for a variable leaves it unchanged, so a
merge-retry revise re-fans over the same list.Gates are resolved through stratum_gate_resolve with runId, stepId, decision, and the observation-time gateToken. The token fences stale decisions. The CLI uses approve, revise, and reject, where reject maps to the engine's kill decision.
on_approve, or completes the gate when the route is null.on_revise while round limits remain.on_kill, or terminates the run when the route is null.Gate policies, policy fallbacks, and gate timeouts were not ported to v1. Resolve every waiting gate explicitly.
Each successful revise increments the flow round counter and the gate's local revision counter. The flow and optional gate limits are enforced before another revision begins.
Steps with run invoke a subflow defined in the same spec. The parent step completes only after the child flow produces and validates its output.
version: 1
contracts:
TestResult:
all_passed: boolean
DeployResult:
status: string
flows:
entry: deploy
integration_tests:
input:
project: string
output:
from: "${run_tests.output}"
contract: TestResult
steps:
- id: run_tests
do: "Run integration tests for ${input.project}"
out: TestResult
ensure:
- expr: "result.all_passed == true"
deploy:
input:
project: string
output:
from: "${release.output}"
contract: DeployResult
steps:
- id: test
run: integration_tests
with:
project: "${input.project}"
- id: release
after: [test]
do: "Deploy after test result ${test.output.all_passed}"
out: DeployResult
with values may contain nested literals and ${...} references.with keys must exactly match the callee input fields.budget and on_fail.run or a fanout.on_fail Recovery Routingon_fail activates a named recovery step after a task, fanout, or subflow exhausts its failure path. The target must be later in the validated DAG.
version: 1
contracts:
TaskResult:
ok: boolean
details: string
flows:
entry: build
build:
input:
spec: string
output:
from: "${publish.output}"
contract: TaskResult
steps:
- id: generate
do: "Generate code for ${input.spec}"
out: TaskResult
attempts: 2
on_fail: recover
- id: verify
after: [generate]
when: "result.generate.ok == true"
do: "Verify the generated code"
out: TaskResult
- id: recover
do: "Repair generation failure"
out: TaskResult
- id: publish
after: [verify, recover]
do: "Publish the final result"
out: TaskResult
Route-only recovery steps are skipped when their source succeeds. Downstream steps may depend on both the normal and recovery branches because skipped dependencies satisfy ordering edges.
V1 has no next field. Success follows the DAG formed by after and step-output references. Gates provide explicit approve and kill routes. Backward success jumps are not supported.
when is a positive v1 expression. The step runs only when the expression evaluates to true. Any other result skips the step. To migrate skip_if, invert the condition. A skipped step has no output, so later data references to it cannot resolve.
iterate repeats a do task until its predicate holds or its positive max is reached.
version: 1
contracts:
Draft:
text: string
quality: number
flows:
entry: refine
refine:
input:
brief: string
output:
from: "${improve.output}"
contract: Draft
steps:
- id: improve
do: "Improve the draft for ${input.brief}"
out: Draft
iterate:
max: 5
until: "result.quality >= 0.95"
The engine evaluates until after contracts and ensures pass. A false result records the attempt and redispatches the task. Repeated identical output stops early. Max exhaustion enters the normal failure path, including on_fail routing.
V0.x score_expr, accumulate, and accumulate_key have no v1 equivalent. V1 also has no separate iteration lifecycle tools. Iteration is part of normal task dispatch and result reporting.
Checkpoints are runtime operations, not YAML constructs. stratum_commit accepts flow_id and a non-empty label. stratum_revert accepts the same fields and re-advances the restored run.
{"flow_id": "<run-id>", "label": "after_analysis"}
A checkpoint captures run status, output, failure, spent budget, rounds, step state, audit events, cancellation state, and parallel state. It does not snapshot the spec, input, workspace files, run identity, or the checkpoint list itself. Commit and revert are state-only operations. The caller owns file-level rollback.
Checkpoints are stored with the persisted run and survive server restarts. Reusing a label replaces its snapshot while keeping the label's original insertion position.