Public
Watch
1
0
Fork
You've already forked stratum
0
mirror of https://github.com/smartmemory/stratum.git synced 2026-08-21 15:43:30 +00:00
Find a file
SmartMemory Dev 6c2da9298f fix(guard): make the sshsig verifier accept nothing OpenSSH would reject
Four findings from an adversarial review of the verifier, all reproduced locally
before fixing, all now regression-tested.

The governing invariant, stated once and applied to all four: ACCEPT NOTHING
OPENSSH WOULD REJECT. A divergence is not cosmetic — it means an operator
auditing with `ssh-keygen -Y verify` reaches a different conclusion than the code
that actually authorizes the change, which is precisely the kind of quiet lie
this subsystem exists to prevent.

1. SMALL-ORDER KEYS (medium, conditional). The verifier checked key type and
   length but not the point. Reproduced: the identity point with R=identity, S=0
   verifies ANY message with no private key. OpenSSH 10.3 accepts the same
   vector, so this is an OpenSSH-compatible weakness rather than a differential.
   Exploiting it needs such a key in the trust root, and anyone who can put it
   there could enrol a real key instead — so the security value is small. The
   value is that a corrupted line in a hand-edited trust root must not silently
   become an anchor that authorizes everything. Rejected at enrolment.

2. LOSSY NAMESPACE DECODING (low). Strings were decoded with U+FFFD replacement,
   so two distinct wire encodings compared equal here while OpenSSH rejected one.
   Non-ASCII in the signed object is now refused rather than normalised, the
   namespace is compared byte-exactly, and an empty namespace is refused (the
   protocol requires non-empty).

3. ALLOWED_SIGNERS DIVERGENCE (low). Unicode-aware trim/\s and Node's permissive
   base64 accepted lines OpenSSH does not authorize: a BOM before the principal,
   a non-breaking space between fields, redundant `==` on the key. Each would
   have meant trusting a key `ssh-keygen` considers unauthorized or malformed.
   Fields are now ASCII space/tab only, and keys must survive a canonical base64
   round trip.

4. ARMOR MALLEABILITY (informational). Markers were searched for anywhere and
   the body decoded permissively, so junk before the header, a missing newline
   after it, and redundant padding all verified — all rejected by OpenSSH. The
   header must now be at byte zero followed by a newline, with nothing after the
   footer. Representation malleability only; the signed object never changed.

Also pinned the ed25519 signature length to 64 bytes.

Checks the review confirmed already correct: the signed-data construction
matches PROTOCOL.sshsig exactly; declared-length truncation and trailing bytes
are rejected; key and signature algorithms are both pinned to ssh-ed25519; hash
selection is limited to exact sha256/sha512; and S+L, 63-byte and 65-byte
signatures are rejected by OpenSSL.

Full suite 903 passed, 1 skipped.
6c2da9298f · 2026-08-17 21:05:18 +08:00
526 commits
2026-08-08 14:04:35 +08:00

Stratum

License

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. Runs from this checkout; not published to a registry.

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 the python-legacy branch, and PyPI packages are frozen at their final releases. The engine executes version: 1 specs exclusively. Legacy v0.x specs are rejected by validate and classified (report-only) by stratum migrate --check. The authoritative v1 shape is the Zod IR schema in ts/src/ir/ plus stratum validate output.

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.


Table of Contents


Installation

The engine runs directly from a checkout — there is nothing to install from a registry.

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).

MCP Server (for Claude Code)

Register the MCP bin in your project's .mcp.json:

{
  "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.

CLI

node ts/src/cli/bin.mjs help    # validate | migrate | query | gate | guard | watch

A thin wrapper script (e.g. ~/bin/stratum-ts) pointing at ts/src/cli/bin.mjs is convenient; the bare name stratum is not used to avoid PATH collisions.


Quick Start

When Claude Code has Stratum installed, it uses it automatically for non-trivial tasks:

  1. Claude writes a .stratum.yaml spec internally (never shown to you)
  2. Calls stratum_plan to validate the spec and get the first step
  3. Executes each step using its own tools (reading files, writing code, running tests)
  4. Calls stratum_step_done after each step -- the server checks postconditions
  5. If a postcondition fails, Claude gets back the specific violation and retries
  6. Calls stratum_audit at the end for a full execution trace

You see plain English narration throughout. The spec, state management, and postcondition enforcement happen behind the scenes.


Core Concepts

Specification vs Flow

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.

Flows

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.

Steps

A step has an id, optional after dependencies, an optional when condition, and exactly one construct: do, set, gate, fanout, or run.

Tasks

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

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

Ensures are structured postconditions on do, set, and fanout stage results. V1 supports expression, file existence, file content, and judged predicates.

Retries

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.

Gates

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.


YAML Spec Reference

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.

Minimal Example

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

Full Example with a Gate

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

Full Field Reference

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

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]}.

Steps

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.

References

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

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.

Migrating v0.x Specs

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.


MCP Tools API

All tools are exposed via the MCP protocol. Claude Code calls them as tool invocations.

stratum_validate

Validate a .stratum.yaml spec without creating a flow.

Inputs: spec (str, inline YAML) Returns: {valid: bool, errors: list}

stratum_plan

Validate a spec, create execution state, and return the first step to execute.

Inputs:

  • spec (str) -- inline YAML
  • flow (str) -- flow name
  • inputs (dict) -- flow-level inputs

Returns: Step dispatch object with status: "execute_step" or status: "await_gate", including:

  • flow_id -- unique identifier for this execution
  • step_id, step_number, total_steps
  • function, intent, inputs (resolved)
  • output_contract, output_fields, ensure
  • retries_remaining
  • agent, step_mode

stratum_step_done

Report 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 contract

Returns one of:

  • Next step to execute (status: "execute_step")
  • Ensure failure with retry info (status: "ensure_failed", violations, retries_remaining)
  • Schema validation failure (status: "schema_failed", violations)
  • Flow completion (status: "complete", output, trace, total_duration_ms)
  • Retries exhausted (status: "error", error_type: "retries_exhausted")
  • Routed to recovery step (routed_from, violations)

stratum_audit

Return the full execution trace for a flow.

Inputs: flow_id (str)

Returns:

  • flow_id, flow_name, status (complete, in_progress, killed)
  • steps_completed, total_steps
  • trace -- array of step records (step_id, function_name, attempts, duration_ms, type, round)
  • round, rounds -- round history for gate revise cycles
  • iterations, archived_iterations -- iteration history
  • child_audits -- audit snapshots from sub-flow executions
  • total_duration_ms

stratum_gate_resolve

Resolve a gate step with a human/agent/system decision.

Inputs:

  • flow_id (str)
  • step_id (str) -- must be the current gate step
  • outcome (str) -- "approve", "revise", or "kill"
  • rationale (str) -- human-readable reason
  • resolved_by (str) -- "human", "agent", or "system"

Returns: Next step, flow completion, or flow termination.

stratum_commit

Save a named checkpoint of the current flow state.

Inputs: flow_id (str), label (str)

stratum_revert

Roll back flow state to a previously committed checkpoint.

Inputs: flow_id (str), label (str)

stratum_compile_speckit

Compile 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_resume

Rehydrate 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_bg

Detached background flow execution: start a flow under a background driver, poll its progress, cancel it. Survives the launching session.

stratum_agent_run / stratum_agent_poll / stratum_cancel_agent_run

Dispatch an agent (Claude, Codex, opencode) as part of a flow step, synchronously or in the background; poll and cancel background runs. cancel is a documented no-op for synchronous runs.

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/distill/goal surfaces) live on python-legacy; see the Phase 2/3 usage audit in docs/plans/2026-07-11-strat-py-retire-progress.md for per-tool dispositions.


STRAT-GUARD — guarded transitions outside a flow

Authorization model. Three policy-change capabilities of increasing power: stratum_guard_upgrade needs no authorization because it is provably non-weakening; stratum_guard_apply_upgrade applies a signed, pre-reviewed descriptor; stratum_guard_migrate does anything, under a signed one-shot authorization. Plus stratum_guard_override, which bypasses predicate verification on one legal edge, also under a signed one-shot authorization. All signing keys are enrolled in contracts/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_register

Register 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_transition

Attempt 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_override

The 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. See docs/features/STRAT-GUARD-AUTHZ/design.md.

Inputs: resource_id, from_state, to_state, authorization, rationale, resolved_by ("human"). Returns: adds authorized_by.

stratum_guard_migrate

Evolve 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_upgrade

Routine 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_upgrade

Apply 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_history

Return 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: [...]}.


Step Types

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 Tasks

do 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 Steps

set 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 Steps

gate 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 Steps

fanout.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 Steps

run 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.

Mode Exclusion

Every step must have exactly one of do, set, gate, fanout, or run. Fields from one construct cannot be mixed into another.


Ensures (Postconditions)

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.

Expression Syntax

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.

Built-in Functions

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

Safety

  • Only result, input, item, and prev can be bound.
  • __proto__, constructor, and prototype member access is blocked.
  • Parsing, nesting, regular expression size, and regular expression input are bounded.
  • File helpers cannot escape the configured workspace root.

Failure Behavior

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 and Output Validation

Contracts in the Spec

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.

Output Schema (Per-Step JSON Schema)

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 (Human-in-the-Loop)

Gates are approval checkpoints that pause flow execution until resolved externally.

Defining a Gate

Declare the gate directly on a step, as shown in the full gate example. There is no gate function declaration in v1.

Gate Constraints

  • on_approve, on_revise, and on_kill are all required and nullable.
  • Named approve and kill targets must preserve an acyclic routing graph.
  • A usable revise target must be a strict ancestor of the gate.
  • Any non-null revise target requires flow-level max_rounds.
  • Gate-level max_rounds may impose a tighter positive limit on that gate.
  • A gate step accepts only common fields and the nested gate object.

Resolution

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.

  • approve routes to on_approve, or completes the gate when the route is null.
  • revise resets the affected descendant region and routes to on_revise while round limits remain.
  • kill routes to 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.

Rounds

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.


Flow Composition

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.
  • The run step inherits the callee output contract.
  • A run step may set budget and on_fail.
  • Recursive calls are rejected.
  • Non-entry flows cannot contain another run or a fanout.

Routing

on_fail Recovery Routing

on_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.

Success Routing

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.

Conditional Skip

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.


Iterations

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.

Removed Iteration Fields

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

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.


Recovery and Retry Logic

Retry Budget

do tasks and fanout steps use attempts, with a default of two. A fanout stage can override the enclosing fanout attempt limit. set is deterministic and does not retry. run does not accept an attempts field.

Retry Flow

  1. The executor reports a task result with the current dispatch token.
  2. The engine validates the result against out.
  3. The engine evaluates ensures in declaration order.
  4. The engine evaluates iterate.until when iteration is configured.
  5. A failure redispatches the task while attempts remain and evidence changed.
  6. Exhaustion activates on_fail when configured, otherwise it fails the flow.

Persistence

Flow state is persisted to ~/.stratum/ts/flows/{runId}.json after state mutations. Runs survive MCP server restarts.

Server Restart Recovery

The engine loads a missing run from disk, revalidates its persisted spec, and resumes from persisted DAG state. Dispatch and gate tokens prevent stale work from mutating a newer issuance.


Workflows

V1 has no top-level workflow declaration and no discovery registry. Name the file for people, and select the executable entry flow with flows.entry. The former code-review workflow becomes a normal v1 specification:

version: 1
contracts:
  ReviewResult:
    summary: string
flows:
  entry: code_review
  code_review:
    input:
      files: string[]
      depth: string?
    output:
      from: "${review.output}"
      contract: ReviewResult
    steps:
      - id: review
        do: "Review ${input.files} for security, logic, and performance"
        out: ReviewResult

V1 input contracts support optional fields, but not default values. The caller supplies defaults before planning a run.


Task Compiler

The task compiler converts spec-kit task files (tasks/*.md) into .stratum.yaml flows.

Task File Format

# Task: [P] Implement authentication

Add JWT-based authentication to the API.

## Acceptance Criteria

- [ ] file src/auth/middleware.ts exists
- [ ] file src/auth/middleware.ts contains "verifyToken"
- [ ] tests pass
- [ ] no lint errors
- [ ] Error messages are user-friendly

Compilation Rules

  • [P] in the title marks the task as parallelizable
  • file X exists compiles to file_exists("X")
  • file X contains Y compiles to file_contains("X", "Y")
  • tests pass compiles to result.tests_pass == True
  • no lint errors compiles to result.lint_clean == True
  • Freeform criteria are incorporated into the step's intent

Dependency Graph

  • Sequential tasks depend on the prior task
  • Parallel tasks ([P]) share the same predecessor with no edges between them
  • After a parallel group, the next sequential task depends on all tasks in the group

Usage

Via MCP tool: stratum_compile_speckit(tasks_dir, flow_name).


Skills

The eleven /stratum-* skills shipped and were installed by the python stratum-mcp install command, which is retired — their sources are archived on python-legacy (stratum-mcp/src/stratum_mcp/skills/). Skills already installed under ~/.claude/skills/ keep working (they drive the same stratum_plan / stratum_step_done / stratum_audit tools the TS server exposes). There is no TS installer yet.


CLI Reference

Two bins, both run with node from the checkout:

node ts/src/mcp/bin.mjs            # Start stdio MCP server (for Claude Code)

node ts/src/cli/bin.mjs <command>  # aka `stratum` via a wrapper script:
stratum validate <file>            # Validate a .stratum.yaml spec (version: 1)
stratum migrate --check <file>     # Report-only classification of a legacy v0.x spec
stratum query flows                # List all persisted flows (JSON)
stratum query flow <id>            # Full state for a single flow (JSON)
stratum query gates                # List all pending gate steps (JSON)
stratum gate approve <flow_id> <step_id> [--note "reason"]
stratum gate reject  <flow_id> <step_id> [--note "reason"]
stratum gate revise  <flow_id> <step_id> [--note "reason"]
stratum guard <action>             # Guard ledger operations (see STRAT-GUARD)
stratum watch <flow_id>            # Follow a flow's progress
stratum help                       # Show usage

Configuration

Flow State Storage

Persisted flows are stored in ~/.stratum/flows/{flow_id}.json. This directory is created automatically.

Hooks

The python installer's session hooks (~/.stratum/hooks/) are retired with it; sources are archived on python-legacy. Any hooks still registered in .claude/settings.json from an old install are inert once removed from there.

MCP Registration

The MCP server is registered in the project's .mcp.json:

{
  "mcpServers": {
    "stratum": {
      "command": "node",
      "args": ["/absolute/path/to/stratum/ts/src/mcp/bin.mjs"]
    }
  }
}

CLAUDE.md Block

The execution model block appended to CLAUDE.md instructs the agent to use Stratum for non-trivial tasks:

## Stratum Execution Model

For non-trivial tasks, use Stratum internally:
1. Write a .stratum.yaml spec -- never show it to the user
2. Call stratum_plan to validate and get the first step
3. Narrate progress in plain English as you execute each step
4. Call stratum_step_done after each step -- the server checks your work
5. If a step fails postconditions, fix it silently and retry
6. Call stratum_audit at the end and include the trace in the commit

Python Library (Track 1)

RETIRED.

The stratum-py decorator library (@infer, @contract, @compute, @flow, @refine, parallel, debate, await_human, budgets, OTLP export) was retired with the python engine on 2026-07-18 (STRAT-PY-RETIRE). Its final source is archived on the python-legacy branch (src/stratum/), and its last PyPI release stays installable for existing users but receives no further updates.


Examples

Working examples in examples/:

Directory What it demonstrates
custom-tracker/ Minimal MCP server exposing a project tracker over stdio (the compose-mcp pattern)
nextjs/ Embedding Stratum pipeline monitoring into a Next.js app

The python decorator examples (01_sentiment.py06_hitl.py) retired with stratum-py; see python-legacy.


Development

git clone https://github.com/smartmemory/stratum
cd stratum/ts
npm install                      # or pnpm install
./node_modules/.bin/vitest run   # full engine suite

Never run two full vitest passes concurrently — the suites share on-disk state roots.

Test Counts

The TS engine has 640+ vitest tests across the IR, engine, MCP surface, guard, parallel, and migrate suites, including cross-engine goldens that pin byte compatibility with python-era ledgers and specs. Compose's suite (4,600+ tests, including the ts-cutover-* goldens that drive this engine's real binaries end-to-end) is the downstream integration harness.

CI/CD

PyPI publishing is retired with the python packages. There is no npm publish pipeline yet — consumers run the engine from a checkout (see Installation).


License

Apache 2.0