Loom

Type to search across the guides, specification and decision records.

↑↓ navigate ↵ open esc close Pagefind

Reference

API reference

Per-package reference — every workspace package that ships typings, apart from three test and scaffolding tools, has a section here (packages/spec/src/apiReferenceDocs.test.ts fails the build if one does not), each covers its main public surface, and the package’s own index.ts or .d.ts is authoritative for anything not listed. Signatures are quoted from source; types owned by another package are cross-referenced, not repeated. Most apps import everything from @loom/sdk (the public barrel) or the individual @loom/* packages shown here.

New to Loom? Start with Getting Started and the User Guide; this document is the lookup reference.

Table of Contents

  1. Authoring builders (@loom/core) — flow$, step$, handler$, tool$, agent$, skill$, projection$, memory$, policy$ + shared types
  2. Composition (@loom/app, @loom/middleware, @loom/registry) — defineConfig, storage, effect middleware, the A2A bus
  3. WorkflowRuntime — start / resume / fork / loadState + WorkflowContext + ExecutionHandle
  4. Replay & storage — @loom/replay-vm, @loom/event-runtime, @loom/snapshot-runtime
  5. Effects (@loom/effect-runtime) — Effect, EffectRuntime, middleware, the four effect kinds, K2 capability ring + K12 delegation
  6. Kernel hardening & security — the K1–K12 / T1–T2 ring map + @loom/isolate (K7), @loom/plugin-crypto (K10), @loom/wire-abi (T2)
  7. Projection & UI — @loom/projection-runtime, @loom/agent-surface, @loom/plugin-a2ui, @loom/plugin-mcp-app, @loom/blueprint-runtime, @loom/plugin-renderer-react, @loom/plugin-renderer-terminal, @loom/plugin-renderer-dom
  8. Agentic — @loom/llm, @loom/reasoning, @loom/agent, @loom/plugin-mcp, @loom/plugin-a2a, @loom/conversation, @loom/plugin-conversation-sse, @loom/capability, @loom/intent, @loom/planner, @loom/memory, @loom/orchestrator
  9. Compute & distribution — @loom/dispatcher, @loom/worker-runtime (+ -local), @loom/plugin-worker-remote, @loom/plugin-worker-server, @loom/plugin-cloudrun, @loom/plugin-postgres, @loom/graph-executor, @loom/scheduler
  10. DX & tooling — @loom/analytics, @loom/appgen, @loom/appgen-model, @loom/cli, @loom/testing, @loom/eval, @loom/deploy-sim, @loom/scaffold, @loom/compiler, @loom/telemetry
  11. Errors

Loom 2.0. Entries new or extended in the execution-kernel evolution are marked (2.0) inline. New packages: @loom/reasoning, @loom/plugin-postgres, @loom/analytics, @loom/appgen, and the kernel-hardening trio @loom/isolate (K7), @loom/plugin-crypto (K10), @loom/wire-abi (T2). For data structures, algorithms, and diagrams see docs/loom-2.0/ (loom/docs/loom-2.0/00-overview.md).


Authoring builders — @loom/core

Declarative $ builders produce plain, serializable definition objects (each tagged with a _tag). They have no runtime behavior on their own — the runtime interprets them.

Builders

flow$<TIn, TOut>(config: FlowConfig<TIn, TOut>): FlowDefinition<TIn, TOut>
step$<TIn, TOut>(config: StepConfig<TIn, TOut>): StepDefinition<TIn, TOut>
handler$<TIn, TOut>(fn: HandlerFn<TIn, TOut>): HandlerRef<TIn, TOut>
tool$<TIn, TOut>(config: ToolConfig<TIn, TOut>): ToolDefinition<TIn, TOut>
agent$(config: AgentConfig): AgentDefinition
skill$(config: SkillConfig): SkillDefinition
instructions$(text: string): InstructionsRef
projection$<TState>(config: ProjectionConfig<TState>): ProjectionDefinition<TState>
memory$(config: MemoryConfig): MemoryDefinition
policy$(config: PolicyConfig): PolicyDefinition
loadInstructions(spec: InstructionsSpec): Promise<string>   // resolve inline or lazy-chunked text

Config shapes:

interface FlowConfig<TIn, TOut> {
  id: string; name: string; version: string; description?: string;
  inputSchema: z.ZodSchema<TIn>; outputSchema: z.ZodSchema<TOut>;
  steps: StepDefinition<any, any>[]; policies?: PolicyDefinition[];
  deadlineMs?: number; resumeSchemas?: Readonly<Record<string, z.ZodTypeAny>>;   // see FlowDefinition; also effectClass?, codeFingerprint?, compatibleWith?, stepFamilies?
}

interface StepConfig<TIn, TOut> {
  id: string; name?: string;
  toolId?: string;                 // resolve to a registered effect …
  handler?: HandlerSpec<TIn, TOut>; // … or carry a co-located handler (handler$)
  inputSchema: z.ZodSchema<TIn>; outputSchema: z.ZodSchema<TOut>;
  retry?: RetryPolicy; timeoutMs?: number;
  errors?: Record<string, z.ZodSchema>;   // also effectClass?, reachableTools?
}

interface ToolConfig<TIn, TOut> {
  id: string; name: string; description: string;
  kind?: EffectKind;               // default "tool"
  inputSchema: z.ZodSchema<TIn>; outputSchema: z.ZodSchema<TOut>;
  idempotent?: boolean; errors?: Record<string, z.ZodSchema>; mode?: EffectMode;   // as on ToolDefinition; also jsonInputSchema?, trustJsonSchemaText?, returnsMedia?, egress?, tags?, aliases?
}

interface AgentConfig {
  id: string; name: string; description?: string;
  tools: ToolDefinition[]; policies?: PolicyDefinition[];
  memoryId?: string;               // links an @loom/memory MemoryRuntime
  version?: string; capabilities?: { streaming?: boolean }; skills?: AgentSkillDef[];
}

interface SkillConfig {
  id: string; name: string; description: string; tags?: string[];
  instructions: InstructionsSpec;  // instructions$("…") or a lazy loader
  aliases?: string[];
  tools?: ToolDefinition[];
}

interface ProjectionConfig<TState> {
  id: string; name: string; sourceFlowId?: string;
  initialState: TState;
  handlers: ProjectionHandlersSpec<TState>;  // Record<eventType, (state: TState, event: EventEnvelope) => TState> | a compiler-emitted ProjectionHandlersRef
  version?: string;                // declared binding version (ADR 0095)
}

interface MemoryConfig { id: string; name: string; ttlMs?: number; maxEntries?: number; }
interface PolicyConfig { id: string; name: string; rules: Record<string, unknown>; }

Definition & handler types

type EffectKind = "tool" | "llm" | "human" | "a2a" | (string & {});   // open (ADR 0115): any /^[a-z][a-z0-9._-]{0,63}$/; only "human" has kernel semantics (parks)

interface StepHandlerContext {
  executionId: string; stepId: string; workflowId: string; attempt: number;
  emit(eventType: string, payload: unknown): void;
}
type HandlerFn<TIn, TOut> = (input: TIn, ctx: StepHandlerContext) => Promise<TOut>;
interface HandlerRef<TIn, TOut> { readonly _loomHandlerRef: true; readonly fn: HandlerFn<TIn, TOut>; }
type HandlerLoader<TIn, TOut> = () => Promise<{ default: HandlerFn<TIn, TOut> }>; // compiler-emitted
type HandlerSpec<TIn, TOut> = HandlerRef<TIn, TOut> | HandlerLoader<TIn, TOut>;

interface RetryPolicy { maxAttempts: number; backoffMs?: number; backoffMultiplier?: number; }

interface StepDefinition<TIn, TOut> {
  readonly _tag: "StepDefinition"; id: string; name: string;
  toolId?: string; handler?: HandlerSpec<TIn, TOut>;
  inputSchema: ZodSchema<TIn>; outputSchema: ZodSchema<TOut>; retry?: RetryPolicy;
  timeoutMs?: number;    // (2.0, K8) bounds a single effect
  // K9 delivery semantics (`idempotent`) live on ToolDefinition / the registered Effect, not on the step
}
interface FlowDefinition<TIn, TOut> {
  readonly _tag: "FlowDefinition"; id: string; name: string; version: string; description?: string;
  inputSchema: ZodSchema<TIn>; outputSchema: ZodSchema<TOut>;
  steps: StepDefinition<any, any>[]; policies?: PolicyDefinition[];
  deadlineMs?: number;   // (2.0, K8.3) wall-clock deadline for a live run ⇒ kernel EXECUTION_CANCELLED
}
interface ToolDefinition<TIn, TOut> {
  readonly _tag: "ToolDefinition"; id: string; name: string; description: string;
  kind: EffectKind; inputSchema: ZodSchema<TIn>; outputSchema: ZodSchema<TOut>;
  jsonInputSchema?: Record<string, unknown>; // R4b — JSON Schema input (MCP); bounded + sanitised before a prompt
  trustJsonSchemaText?: boolean;     // R4b — operator vouches for its description/title text
  idempotent?: boolean;              // K9 delivery semantic
  errors?: Record<string, ZodSchema>;// ADR 0060 declared failure channels
  mode?: EffectMode;                 // ADR 0073 — which invocation seam (below)
}
// ADR 0073 — which side of the determinism boundary a tool may be invoked from.
//   "journaled" — runs inside an execution; result recorded and replay-substituted.
//   "ambient"   — an enrichment read (pagination, drill-down, a surface refetch) that
//                 must NEVER append to the log; runs only via invokeTransient.
// `execute` refuses an ambient effect; `invokeTransient` refuses a journaled one
// (EffectModeViolationError). Undeclared = today's behaviour on both seams (G2).
// A boundary CHECK, not a journaling mode: an ambient tool is not "an effect that
// skips its journal" — it may not enter an execution at all, so there remains
// exactly one replay model. Declaration lives here; the enforced copy is on the
// registered effect (`new ToolEffect(fn, { mode })`).
type EffectMode = "journaled" | "ambient";
interface AgentDefinition {
  readonly _tag: "AgentDefinition"; id: string; name: string; description?: string;
  tools: ToolDefinition[]; policies?: PolicyDefinition[]; memoryId?: string;
  version?: string; capabilities?: { streaming?: boolean }; skills?: AgentSkillDef[];
}
// Also: SkillDefinition, ProjectionDefinition, MemoryDefinition, PolicyDefinition, InstructionsRef/Spec/Loader.

Shared runtime types

These flow through the whole system.

interface EventEnvelope {
  id: string; workflowId: string; executionId: string;
  eventType: string; sequence: number; timestamp: number;
  payload: unknown; metadata?: Record<string, unknown>;
}

type ExecutionStatus = "pending" | "running" | "suspended" | "completed" | "failed" | "cancelled" | "continued";
type FrameStatus     = "pending" | "running" | "completed" | "failed" | "skipped" | "cancelled";

interface ExecutionFrame {
  stepId: string; status: FrameStatus;
  input?: unknown; output?: unknown; error?: unknown;
  startedAt?: number; completedAt?: number; attempts: number;
}
interface SuspendCondition { on: string; timeout?: number; correlationKey?: string; }

interface ExecutionState {
  executionId: string; workflowId: string; workflowVersion: string;
  status: ExecutionStatus;
  frames: Record<string, ExecutionFrame>;   // one per step (keyed by stepId)
  variables: Record<string, unknown>;       // holds _input / _output / _error / _resumePayload …
  sequence: number; suspendedOn?: SuspendCondition; startedAt: number; updatedAt: number;
}

Reserved undefined keys (2.0): _decisions (logged ctx.decide choices, P9), _denials (capability EFFECT_DENIED audit, K2), _resources (metered totals { tokens, costUsd, ms, count, exceeded? }, K3), _blocked (behavioral-safety SAFETY_BLOCKED audit, K6), _indoubt (parked EFFECT_INDOUBT deliveries, K9), and _shredded (crypto-shred PAYLOAD_SHREDDED tombstones, K10). The full 13-key table is in the event-schema reference (loom/docs/loom-2.0/10-event-schema-reference.md).

Tasks & the process-control-block (2.0). A step generalizes to a task; the minimal Execution PCB and its out-of-core ExecutionPolicy sidecar carry scheduling/compute/memory intent without bloating the core.

type TaskKind = "step" | "llm" | "agent" | "tool" | "memory" | "human" | "plan" | (string & {});   // open (ADR 0115)
interface TaskDefinition<TIn, TOut> {
  readonly _tag: "TaskDefinition"; kind: TaskKind; id: string; name?: string;
  toolId?: string; inputSchema?: ZodSchema<TIn>; outputSchema?: ZodSchema<TOut>;
  retry?: RetryPolicy; timeoutMs?: number;
}
type ExecutableTask<TIn, TOut> = StepDefinition<TIn, TOut> | TaskDefinition<TIn, TOut>;

interface Execution {                         // minimal PCB — a projection of ExecutionState
  id: string; status: ExecutionStatus; frames: Record<string, ExecutionFrame>;
  sequence: number; parent?: string; children?: string[]; tags?: string[];
}
interface ExecutionPolicy {                   // opt-in sidecar (owned by scheduler/compute/reasoning)
  executionId?: string; priority?: number;
  resources?: { cpu?: number; memoryMb?: number; gpu?: boolean };
  computeTarget?: string; memoryScope?: string; affinity?: string; retryPolicy?: RetryPolicy; tags?: string[];
}
function executionFromState(state: ExecutionState, opts?: { parent?: string; children?: string[]; tags?: string[] }): Execution;

State is a pure fold of the event log — see undefined.

type VersionPins = Readonly<Record<string, string>>;
function checkVersionPins(recorded: VersionPins | undefined, current: VersionPins | undefined): PinReport   // compares recorded vs registered versions; REPORTS, never throws; O(|recorded| + |current|)
interface PinReport { readonly ok: boolean; readonly findings: readonly PinFinding[]; /* …plus `compared`: ids where BOTH sides declared a version (ok over compared 0 is not a pass) */ }
type PinFindingCode = "drift" | "unpinned";                                  // drift: both declare, differ; unpinned: one side declares nothing (a warning, ok stays true)
function legacyFlowContentHash<TIn, TOut>(definition: FlowDefinition<TIn, TOut>): string   // the pre-ADR-0113 "fnv1a:" hash, kept only so an execution that recorded one is not stranded; new starts never record it

Composition — @loom/app, @loom/middleware, @loom/registry

runAgentOnce (@loom/app)

Scripts and tests only; production runs agentLoop$ in a registered flow on durable storage. Builds a throwaway in-memory defineConfig app plus a one-off flow, runs agentLoop$ to completion (or to a park) and returns the result and the events. It goes through defineConfig (so config.security applies), registers the model as a ring effect, and rejects with agentLoop$’s own error for a config it refuses.

function runAgentOnce(options: RunAgentOnceOptions): Promise<RunAgentOnceResult>

interface RunAgentOnceOptions {
  agent: AgentDefinition;
  provider?: LlmProvider;                               // registered as the effect `loop.modelToolId` (default "model")
  registerEffects?: (effects: EffectRuntime) => void | Promise<void>;   // tool effects (or the model effect, when no provider)
  input: string | AgentInput;
  loop?: Omit<AgentLoopConfig, "definition">;
  config?: Omit<LoomConfig, "storage" | "flows" | "flowModules">;
  executionId?: string;
  principal?: PrincipalChain;
}
interface RunAgentOnceResult { output?: AgentOutput; status: ExecutionStatus; executionId: string; events: EventEnvelope[] }   // output absent when suspended

defineConfig (@loom/app)

The composition root: turns one static config into the same runtime + transport you would otherwise hand-assemble.

function defineConfig(config: LoomConfig): LoomApp

type StorageConfig =
  | { kind: "memory" }                           // ephemeral (tests / browser)
  | { kind: "custom";                            // BRING YOUR OWN backend
      eventStore: EventStore; snapshotStore?: SnapshotStore;   // omitted ⇒ in-memory default (a boot warning for a non-in-memory event store)
      branchRegistry?: BranchRegistry; archiveStore?: ArchiveStore; leaseStore?: LeaseStore };
// SQLite and Postgres are presets that return the custom shape, so @loom/app names no vendor:
//   storage: sqliteStorage({ dir })      from @loom/plugin-sqlite
//   storage: postgresStorage({ url })    from @loom/plugin-postgres
// Any other `kind` throws.

interface LoomConfig {
  storage: StorageConfig;                       // required
  projectionRuntime?: ProjectionRuntime;        // default in-memory
  surfaces?: (string | { id: string; redact?: readonly string[] })[];   // agent surfaces to register
  flows?: RegisteredFlow[];
  flowModules?: FlowModule[];                   // every RegisteredFlow-shaped export is registered
  agentModules?: AgentModule[]; toolModules?: ToolModule[]; skillModules?: SkillModule[];
  scheduleModules?: ScheduleModule[]; memoryModules?: MemoryModule[]; projectionModules?: ProjectionModule[];
  skills?: SkillDefinition[]; memories?: MemoryDefinition[]; memoryStore?: MemoryStore;
  projections?: AnyProjectionDefinition[];
  catalog?: CatalogLike;                        // capability catalog
  agents?: (AgentCard | AgentDefinition)[];     // advertised on the A2A bus
  capabilityAgents?: Record<string, string>;    // capabilityId → agentId
  agentSurface?: boolean;
  autostart?: boolean;
  kernel?: { /* opt-in syscalls: features, continuation, divergence, meterCeilings, … */ };
  timers?: {                                    // (2.0, T1) durable timers & cron
    store: TimerStore; pollMs?: number; schedules?: ScheduleDefinition[]; fenced?: boolean };
  // Cross-cutting options are grouped. A flat key such as `onEvent` throws, naming its group, and
  // `loom migrate-config --apply` rewrites an old config.
  security?: {      // authorizer, policies, secrets, resumeAuthenticator, executionAuthorizer,
                    // onExecutionDenied, compliance, strict, killSwitch, principalCodec,
                    // principalResolver, crypto
  };
  observability?: { // telemetry, onEvent (can fire before the durable write), onDurableEvent (after
                    // it, at most once, ADR 0147), onWarn, onStorageError, reasoning
  };
  durability?: {    // snapshotEvery, retention, heartbeat, snapshots
  };
  execution?: {     // dispatcher, effectMiddleware, claimCheck, registerEffects, now, validateInput,
                    // envelope, errorClasses
  };
  checks?: {        // validate, verify, manifest
  };
}

// Every option that moved into a group, paired with its group — the single table the flat-key
// error is built from and `loom migrate-config` rewrites by (read-only; exported from @loom/app).
const GROUPED_OPTIONS: readonly (readonly [group: string, options: readonly string[]])[]

interface LoomApp {
  runtime: WorkflowRuntime;
  effectRuntime: EffectRuntime;
  projectionRuntime: ProjectionRuntime;
  bus: InProcessA2ABus;
  strict: boolean;                               // (R9) security.strict was enabled
  envelope?: { spawn: SpawnLimits; ledger: BudgetLedger };   // (R9) pass spawn to superviseChildren({ limits })
  killSwitch?: KillSwitchRegistry;               // (R9) supplied, or the in-memory one strict creates
  stores: { eventStore: EventStore; snapshotStore: SnapshotStore; branchRegistry?: BranchRegistry };
  // … also start(), heartbeat, tools, skills, schedules, memory, knowledge(), lifecycle, supervise(), verify(), exportSubject()/eraseSubject(); see LoomApp in the .d.ts
  close(): Promise<void>;
}

interface AutonomyEnvelopeOptions {              // (R9) execution.envelope
  spawn?: { maxDepth?: number | false; maxFanOut?: number | false };     // strict: 5 / 32
  budget?: false | { perExecution?: Budget | false;                       // strict: { maxTokens: 1_000_000 }
                     perAgent?: Budget; perTenant?: Budget; pricing?: Pricing };   // cumulative; no default
  rateLimit?: false | RateLimitOptions;                                   // no default
}
const STRICT_DEFAULTS: { defaultDeadlineMs: 86_400_000; spawnMaxDepth: 5; spawnMaxFanOut: 32; perExecutionMaxTokens: 1_000_000; divergence: "fail" }

Bring your own storage: kind: "custom" uses any async-first EventStore/SnapshotStore (+ optional BranchRegistry) verbatim — Postgres/Redis/DynamoDB (see the Postgres reference (loom/docs/examples/postgres-backend/README.md)). The SQLite stores are lazily loaded, so kind: "memory" never pulls better-sqlite3/node:fs into a browser bundle.

Flow registration & validation (@loom/app)

type FlowModule = Record<string, unknown>;
function registerFlowsFrom(runtime: WorkflowRuntime, ...modules: FlowModule[]): string[]  // ids newly registered
function manifestWarnings(manifest: ManifestView, isRegistered: (id: string) => boolean): string[]

interface FlowGraphIssue { severity: "error" | "warning"; kind: "missing-effect" | "dangling-capability-agent"; detail: string; flowId?: string; }
function validateFlowGraph(input: {
  flows: Array<{ id: string; steps: Array<{ id: string; toolId?: string; handler?: unknown }> }>;
  hasEffect: (toolId: string) => boolean;
  capabilityAgents?: Record<string, string>;
  hasCapability?: (id: string) => boolean;
}): FlowGraphIssue[]

InProcessA2ABus (@loom/app)

The in-process transport defineConfig builds (app.bus); dispatches a capability to its owning agent with streaming callbacks.

class InProcessA2ABus {
  constructor(runtime: WorkflowRuntime, dispatcher?: ExecutionDispatcher, options?: { maxTasks?: number; principal?: Record<string, unknown> })
  registerAgent(card: AgentCard): this
  registerCapability(capabilityId: string, agentId: string, flow: RegisteredFlow): this
  agentCards(): AgentCard[]
  getTask(taskId: string): A2ATaskResult | undefined
  dispatch(capabilityId: string, input: unknown, prompt: string, options?: DispatchOptions): Promise<DispatchResult>
  resolveApproval(taskId: string, decision: ApprovalDecision, options?: Pick<DispatchOptions, "principal" | "onApproval" | "onStatus">): Promise<DispatchResult>   // answer a parked gate later
}
interface DispatchOptions {
  onProgress?: (line: string) => void;
  onStatus?: (status: A2ATaskStatus) => void;
  onApproval?: (request: ApprovalRequest) => Promise<ApprovalDecision>;   // absent ⇒ the gate STAYS parked (R13): result.suspended is set; unsafeAutoApprove is the explicit demo opt-in
  executionId?: string;
  principal?: Record<string, unknown>;
}
interface DispatchResult { taskId: string; status: A2ATaskStatus; artifact: unknown; suspended?: ApprovalRequest; }
interface ApprovalDecision { approved: boolean; approvedBy: string; proof?: ApprovalProof }   // ApprovalProof = { actor, sig, exp }
interface ApprovalRequest { executionId: string; gate: string; viewDigest?: string; parkedAtSequence?: number }

Also exported: BroadcastingProjectionRuntime (a ProjectionRuntime decorator that emits each SURFACE_PATCH to an SSE sink, with snapshot()), progressHub, approvalPayload(request, decision) (the resume payload for an approval) and unsafeAutoApprove (an onApproval that approves everything, for demos).

@loom/middleware — effect middleware

function retryMiddleware(options?: RetryOptions): EffectMiddleware
interface RetryOptions {
  maxRetries?: number;      // default 2 (⇒ up to 3 tries)
  baseDelayMs?: number;     // default 100
  factor?: number;          // default 2
  maxDelayMs?: number;      // default 30_000
  jitter?: boolean;         // default true — DETERMINISTIC full-jitter (no Math.random)
  isRetryable?: (err: unknown) => boolean;
  onRetry?: (info: { ctx: EffectContext; attempt: number; delayMs: number; err: unknown }) => void;
  sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;   // injectable, for tests
}

Never retries a SuspendError or a cancelled (aborted-signal) execution.

Claim-check (ADR 0054, 0140). claimCheckMiddleware offloads a result over the cap to a content store and records an envelope.

function claimCheckMiddleware(options: ClaimCheckOptions): EffectMiddleware
interface ClaimCheckOptions {
  store: ClaimCheckStore; inlineCapBytes?: number;            // default 16 KiB here; defineConfig's execution.claimCheck defaults to 1 KiB
  digests?: Readonly<Record<string, (result: unknown) => unknown>>;   // per-tool digest, the only part orchestration may branch on
  defaultDigest?: (result: unknown, toolId: string | undefined) => unknown;   // for a tool with NO digest entry; unset = { ref, size } only
  onOffload?: (info: ClaimCheckInfo) => void;
  retain?: boolean | ((toolId) => boolean);                   // ADR 0140: mark the envelope retain: true so the sweep never TTL-reclaims the blob
  passThrough?: (result, toolId) => boolean;                  // never offload (e.g. media)
  envelopesAreData?: boolean;                                 // a result that LOOKS like an envelope is force-offloaded as data
}
function previewDigest(maxChars = 256): (result: unknown, toolId?: string) => PreviewDigest | undefined   // { preview, truncated? }; never for a tainted result
// A result that carries an ADR 0085 taint wrapper (top level or nested) is recorded as tainted(envelope, origin): unwrap before reading .ref/.digest/.size.
// @loom/app: defineConfig({ execution: { claimCheck: ClaimCheckConfig } }), DEFAULT_CLAIM_CHECK_CAP_BYTES (1 KiB), DEFAULT_CLAIM_CHECK_PREVIEW_CHARS (256);
//   ClaimCheckConfig = { store, toolIds (required, non-empty), inlineCapBytes?, digests?, preview?: number | false, onOffload?, reclaimable?: true | toolIds[] }

Resource accounting & quotas (2.0, K3). meteringMiddleware meters every effect onto the log and enforces a budget by admission control.

function meteringMiddleware(options: MeteringOptions): EffectMiddleware
interface MeteringOptions { pricing: Pricing; budget?: Budget; clock?: () => number; }
type Pricing = (info: { stepId: string; input: unknown; result: unknown; ms: number }) => ResourceDelta;
interface ResourceDelta { tokens?: number; costUsd?: number; ms?: number; }
interface Budget { maxTokens?: number; maxCostUsd?: number; }        // over-budget ⇒ QUOTA_EXCEEDED + QuotaExceededError
function llmUsageTokens(result: unknown): number;                    // reads { usage: { inputTokens, outputTokens } }
class QuotaExceededError extends Error { readonly reason: string; }

Emits RESOURCE_METERED (folds into variables._resources); replay-safe (metered facts live on the log).

Behavioral safety (2.0, K6). safetyMiddleware runs a chain of validators on each effect’s input and output; a violation blocks the effect and records SAFETY_BLOCKED (folds into variables._blocked).

function safetyMiddleware(options: { validators: readonly SafetyValidator[]; deltaWindow?: number }): EffectMiddleware
type SafetyPhase = "input" | "output" | "delta";
interface SafetyCheckInfo { phase: SafetyPhase; stepId: string; input: unknown;
  result?: unknown;    // "output" phase only
  text?: string; }     // "delta" phase only: the running streamed text
interface SafetyVerdict { ok: boolean; reason?: string }
interface SafetyValidator { id: string; phase: SafetyPhase; category?: string;
  check(info: SafetyCheckInfo): SafetyVerdict;
  checkDelta?; }       // the delta form of an "output" validator; without one, streaming is refused
class SafetyViolationError extends Error { readonly validatorId: string; readonly category: string | undefined; readonly reason: string; readonly retryable: false }
// bundled validators:
function denyPatterns(patterns: readonly RegExp[], opts?: { id?: string; phase?: SafetyPhase; category?: string }): SafetyValidator;
function piiValidator(opts?: { id?: string; phase?: SafetyPhase }): SafetyValidator;   // block emails/SSNs/… in payloads
function schemaValidator(schema: SchemaLike, opts?: { id?: string }): SafetyValidator; // "output" phase
function killSwitch(isTripped: () => boolean, opts?: { id?: string }): SafetyValidator; // "input" phase: global stop

Secrets & redaction (2.0, K10). redactionMiddleware resolves secretRef placeholders in effect input just-in-time (so a secret’s plaintext is never logged) and, with redactOutput, replaces marked result fields with a reference before STEP_COMPLETED is written.

function secretRef(id: string): SecretRef;                            // { __loomSecret: id } — a loggable placeholder
function isSecretRef(value: unknown): value is SecretRef;
interface SecretResolver { resolve(id: string): Promise<unknown> | unknown; store?(id: string, value: unknown): Promise<void> | void; }
class InMemorySecretResolver implements SecretResolver { … }
function redactionMiddleware(options: {
  resolver: SecretResolver;
  redactOutput?: (info: { stepId: string }) => readonly string[];    // output dot-paths to reference-swap (needs resolver.store)
}): EffectMiddleware

Nested budgets (2.0, K12). nestedBudgetMiddleware enforces spend ceilings at several scopes at once (tenant / workflow / execution), tripping QUOTA_EXCEEDED at the tightest breached level.

function nestedBudgetMiddleware(options: {
  pricing: Pricing;                              // same shape as meteringMiddleware
  ledger: BudgetLedger;                          // cross-execution spend projection (tenant/workflow)
  scopesOf: (ctx: EffectContext) => BudgetScope[]; // outermost (broadest) first; execution level uses key ""
  clock?: () => number;
}): EffectMiddleware
interface BudgetScope { level: string; key: string; budget: Budget; }   // budget = { maxTokens?, maxCostUsd? }
class BudgetLedger { /* accumulates spend per scope key */ }

Autonomy envelope & scoped kill switch (R9). rateLimitMiddleware is a token bucket per execution / tenant / workflow / process; killSwitchMiddleware consults a KillSwitchRegistry before every effect. Both refuse with a typed error carrying retryable: false (which retryMiddleware’s default classifier honours) and record SAFETY_BLOCKED (category: "rate-limit" | "kill-switch").

function rateLimitMiddleware(options: {
  capacity: number; refillPerSecond: number;
  scope?: "execution" | "tenant" | "workflow" | "global";   // default "execution"
  clock?: () => number; maxBuckets?: number;                 // LRU-bounded; default 10_000
}): EffectMiddleware
class RateLimitExceededError extends Error { scope; key; retryAfterMs; readonly retryable: false }

interface KillSwitchRegistry { check(ctx: EffectContext): KillVerdict | undefined | Promise<KillVerdict | undefined> }
interface KillVerdict { scope: "system" | "tenant" | "workflow"; key?: string; reason: string }
class InMemoryKillSwitch implements KillSwitchRegistry {
  stopAll(reason?): void; resumeAll(): void;
  stopTenant(tenant, reason?): void; resumeTenant(tenant): void;      // matches ctx.principal.tenant
  stopWorkflow(workflowId, reason?): void; resumeWorkflow(workflowId): void;
  status(): { system?: string; tenants: Record<string,string>; workflows: Record<string,string> }
}
function killSwitchMiddleware(registry: KillSwitchRegistry): EffectMiddleware   // outermost; fails CLOSED if check() throws
class KillSwitchEngagedError extends SafetyViolationError { scope; key?; readonly retryable: false }

The kill switch refuses the NEXT effect. It does not interrupt an in-flight effect or cancel an execution’s children (kernel cancel cascade, separate).

function effectTimingMiddleware(): EffectProfiler      // measures what effects cost; the counterpart to ProfilingEventStore (ADR 0065 gate 1)
interface EffectProfiler { middleware: EffectMiddleware; profile(): EffectProfile; reset(): void }
interface EffectProfile { calls: number; totalEffectMs: number; maxEffectMs: number; peakConcurrentEffects: number; byTool: Record<string, { calls: number; totalMs: number }> }
function streamTokenBudget(budget: Budget, options?: StreamTokenBudgetOptions): (ctx: EffectContext) => (progress: { text: string; deltas: number }) => void
                                                       // the collectStream `guard` that enforces a token Budget mid-flight: on breach emits QUOTA_EXCEEDED and throws QuotaExceededError
interface StreamTokenBudgetOptions { estimate?: TokenEstimator /* default estimateTokensByChars */; checkEvery?: number /* default 8 */ }

@loom/registry — agent registry

class AgentRegistry {
  register(definition: AgentDefinition, card?: RegisteredAgentCard): this
  get(id: string): RegisteredAgent | undefined
  has(id: string): boolean
  all(): RegisteredAgent[]; ids(): string[];
}
const globalAgentRegistry: AgentRegistry;

WorkflowRuntime — @loom/workflow-runtime

The durable execution engine (@loom/workflow-runtime). defineConfig returns one as app.runtime; you can also construct it directly.

interface WorkflowRuntimeDeps {
  eventStore: EventStore; snapshotStore: SnapshotStore; effectRuntime: EffectRuntime;
  onEvent?: (event: EventEnvelope) => void;
  onDurableEvent?: (event: EventEnvelope) => void; // after the durable write (ADR 0147)
  branchRegistry?: BranchRegistry;
  onStorageError?: (executionId: string, err: unknown) => void;
  snapshotEvery?: number;   // mid-run snapshot every N committed events (bounds replay); omit = only at suspend/fork
  heartbeat?: { leaseStore: LeaseStore; ttlMs?: number; renewEveryMs?: number; onStoreError?: "stop" | "continue"; acquire?: "eager" | "lazy"; lazyGraceMs?: number; nodeId?: string }; // heartbeat leases (ADR 0130 D11); defineConfig fills it from durability.heartbeat
}

Durability group (~defineConfig({ durability })~): ~snapshotEvery~, ~retention~, and ~heartbeat: { enabled?, ttlMs?, renewEveryMs?, store?, onStoreError?, acquire?, lazyGraceMs? }~ (ADR 0130 D11). ~acquire~ is ~"lazy"~ by default (S7g): a FRESH ~start~ (a new execution id whose stream does not exist yet) takes its lease only after ~lazyGraceMs~ (default TTL/3) of driving, so a short execution never touches the lease store; ~"eager"~ leases every run before its first append. Every other entry is always eager: ~resume~ (the ~send~/notify winner included), ~resumeAs~, ~restoreFromState~, a continue-as-new successor, a ~start~ on an id whose stream already exists, the poll/sweep re-drive claim, a timer fire and a local worker attempt. Lazy trade-offs: a fresh driver that dies inside its grace window left no lease, so only a host ~isAbandoned~ re-drives it; under ~onStoreError: "stop"~ a lazy acquire of unknown outcome (a store error or timeout) self-fences the run at send time + TTL - margin unless a re-acquire confirms first, and a run that fence left without any row is re-driven only by a host ~isAbandoned~. The lease store is co-located with the event store (the storage preset's ~leaseStore~), in-memory only beside an in-memory event store, else heartbeat is disabled and ~loom doctor~ says so. ~app.heartbeat~ (~HeartbeatStatus~) reports the decision, including ~scope~ (the store's declared optional ~LeaseStore.scope~: process, host, cluster, or unknown) and ~clusterVisible~.

class WorkflowRuntime {
  constructor(deps: WorkflowRuntimeDeps)
  register(toolId: string, effect: Effect): void
  registerFlow<TIn, TOut>(definition: FlowDefinition<TIn, TOut>, runner: FlowRunner<TIn, TOut>): void
  getFlow(flowId: string): RegisteredFlow | undefined
  getFlowIds(): string[]
  invokeTransient<TIn, TOut>(toolId: string, input: TIn, opts?: TransientInvokeOptions): Promise<TOut>  // (ADR 0066) façade over EffectRuntime.invokeTransient — non-durable read-path effect, emits nothing

  start<TIn, TOut>(flow: RegisteredFlow<TIn, TOut> | FlowDefinition<TIn, TOut> | string, input: TIn, options?: RunOptions): Promise<ExecutionHandle>   // string = a registered flow id
  resume(executionId: string, resumeEvent: ResumeEvent, options?: RunOptions): Promise<ExecutionHandle>
  restoreFromState(preloadedState: ExecutionState, resumeEvent?: ResumeEvent, options?: RunOptions): Promise<ExecutionHandle>
  cancel(executionId: string, reason?: unknown, options?: { principal?: PrincipalChain; system?: SystemGrant }): Promise<boolean>
  loadState(executionId: string, options?: { principal?: PrincipalChain; system?: SystemGrant }): Promise<ExecutionState | null>   // fold durable state WITHOUT running
  fork(parentExecutionId: string, atSequence: number, opts?: ForkOptions): Promise<ForkResult>
  resumeAs(executionId: string, newFlowId: string, resumeEvent: ResumeEvent,   // (2.0, K11.3) migrate an in-flight run to new flow code
           options: { strategy: "replay-check" | "operator-ack"; transformState?: (input: unknown) => unknown }
                    & Omit<RunOptions, "executionId" | "allowFlowCodeMismatch">): Promise<ExecutionHandle>
}

Flow-code versioning for in-flight executions (2.0, K11). Each flow carries a flowContentHash (structural fingerprint over step bindings + durable policies, emitted by @loom/compiler and pinned into EXECUTION_STARTED). A resume whose flow code no longer matches the pinned hash throws FlowCodeMismatchError rather than replaying against drifted code; resumeAs deliberately migrates such a run — "replay-check" verifies every recorded step still exists in the new flow (refuses with FlowMigrationCheckError otherwise), "operator-ack" vouches without the check. Either way it records a durable, replay-deterministic EXECUTION_MIGRATED verdict.

Body-aware fingerprint and divergence detection (ADR 0113, R7). flowContentHash is SHA-256 (sha256:; a recorded legacy fnv1a: value is still accepted via flowHashMatches). FlowDefinition.codeFingerprint (toolchain-supplied, optional) is recorded as EXECUTION_STARTED.codeHash and compared on resume: a different value throws FlowCodeMismatchError (kind: "code") unless compatibleWith lists the recorded one. WorkflowRuntimeDeps.divergence / kernel.divergence ("off" | "warn" | "fail", default warn, fail under security.strict) controls the replay-divergence detector, which raises FlowReplayDivergenceError (a FlowDivergenceError) before a step’s effect, a park, a continuation or completion when a recorded completed step was never asked for; in fail mode the execution parks on the reserved gate __loom_diverged__ and retryDiverged(id) re-runs it. kernel.features.sha256FlowHash (default off) makes new starts record sha256: instead of fnv1a:. "replay-check" treats base#… ids as family base and honours FlowDefinition.stepFamilies.

WorkflowRuntime is also exported as ExecutionRuntime (2.0) — the same class, renamed for the execution-kernel framing.

ExecutionContext — the flow API (2.0; undefined is an alias)

The ctx handed to every runner. Every call is durable, ordered, and replayable — and is the kernel’s versioned syscall surface (K1).

interface ExecutionContext {
  readonly executionId: string;
  readonly workflowId: string;
  execute<TIn, TOut>(task: ExecutableTask<TIn, TOut>, input: TIn): Promise<TOut>;      // (2.0) generalized primitive
  run<TIn, TOut>(step: StepDefinition<TIn, TOut>, input: TIn): Promise<TOut>;          // thin alias; memoized on replay
  decide<T>(id: string, decider: (scope: DecideScope) => T | Promise<T>,
            opts?: DecideOptions): Promise<T>;                                           // DecideOptions: { rationale?, source?, alternatives?, estimatedCostUsd?, estimatedCost?, estimatedLatencyMs?, confidence?, context?, policy? }
                                                                                       // (2.0, P9) log an optimizer choice; scope.invokeTransient runs AS this execution (L30)
  parallel<const B extends readonly ParallelBranch[]>(
    branches: B & { readonly [K in keyof B]: CheckedBranch<B[K]> },   // [step, input] pairs: input must match the step
    options?: ParallelOptions                                          // { limit?: number; failFast?: boolean; onPark?: "drain" | "abort" }
  ): Promise<ParallelResults<B>>;                                       // a tuple of each branch's output, in input order
  suspend<T = never>(config: SuspendConfig): Promise<T>;                               // SuspendConfig: { on; timeout?; correlationKey?; wakeAt?; viewDigest? }
  receive<T = unknown>(topic: string): Promise<T>;                                     // (ADR 0110) next inbox message; needs kernel.features.inbox
  select<T = unknown>(arms: readonly SelectArm[]): Promise<SelectResult<T>>;           // (ADR 0110) first of gates / inbox topics / timers; SelectArm = { gate; viewDigest?; correlationKey? } | { inbox } | { timer: { afterMs } | { at } }
  continueAsNew(carry?: unknown): Promise<never>;                                       // (ADR 0109) needs kernel.features.continueAsNew
  sleep(ms: number): Promise<void>;                                                    // (2.0, T1) durable timer
  sleepUntil(wakeAt: number): Promise<void>;                                           // (2.0, T1) wake at absolute ms
  emit(eventType: string, payload: unknown): void;                                     // durable domain event
  stream(eventType: string, delta: string, done?: boolean): void;                      // token streaming
  // `ui` is NOT declared by the kernel (ADR 0114): @loom/agent-surface merges `readonly ui: SurfaceEmitter` into
  // `ExecutionContextExtensions` (deprecated spelling); use `surface(ctx).set/merge/append …` from @loom/agent-surface.
  negotiateBudget(dimension: ResourceDimension, request: number, opts?: { id?: string; ceiling?: number; rationale?: unknown }): Promise<number>;
}

Durable timers (2.0, T1). ctx.sleep/ctx.sleepUntil suspend the execution with the absolute wake time recorded in the log; a wired TimerService fires a durable TIMER_FIRED resume, so a sleep survives restarts and replays deterministically (the scheduled time is in the log — wall-clock drift never enters the fold). Without a timer service the suspension is still durable and resumable by any node. Declare cron-driven starts with schedule$ (see undefined builders) and wire both via defineConfig({ timers }).

Recovering an abandoned execution (undefined). A dead or wedged worker is redispatched by the dispatcher (executeTimeoutMs) and resumes idempotently. A dead supervisor is not covered — detection lives in its process — so recoverAbandoned(runtime, { source, isAbandoned }) (@loom/workflow-runtime) enumerates running/pending executions and resumes the ones your predicate claims, via the same EXECUTION_RECOVERED resume the worker uses. A re-drive answers no gate (ADR 0104): the recovered flow parks at the next ctx.suspend it reaches until a resume names that gate. isAbandoned is REQUIRED: a running execution on a healthy node is byte-identical in the log to an orphaned one, so only the deployment can decide. Run it from a cron or Job, not as a service. A step failure the flow caught leaves the execution running, so it is recovered too (ADR 0105); maxRecoveries skips a claimed execution whose last N re-drives made no progress (consecutive EXECUTION_RECOVERED events since the last STEP_COMPLETED / EXECUTION_SUSPENDED / EXECUTION_RESUMED; ≥ 1, default unbounded) and reports it in summary.exhausted / onExhausted, so a poison execution stops costing every pass. reconcileTimers(runtime, { source, timers }) re-arms the timer row of every execution suspended on a wakeAt (idempotent — schedule dedupes); run it once after upgrading to EVENT_SCHEMA 1.21.0, whose predecessor let TimerService retire the row of a timed park a parallel sibling’s failure had folded to failed. A sealed-payload replay refusal (ADR 0130 D10) leaves a refusal row (a timer-store row carrying TimerRecord.refused: { head }, no log append); a TimerService re-drives that running execution on a node that can open it, but only on the sweep’s own evidence (its isAbandoned option, or with leases an existing expired lease) and only while the log has not moved since the refusal, through the same internal re-drive routine as recoverAbandoned and one shared sealed back-off. isAbandoned on the poll carries the sweep’s contract (a sweep-grade predicate). With neither isAbandoned nor leases the poll cannot prove abandonment and fetches no refusal rows (due({ refused: false })), and the sweep recovers.

*Start-once for a caller-chosen id (ADR 0103).* start(flow, input, { executionId }) makes the execution’s EXECUTION_STARTED durable BEFORE the flow body runs, so a duplicate start (a redelivered trigger, two nodes firing the same schedule occurrence) rejects with ExecutionAlreadyExistsError and nothing runs — no effect, no fold, no onEvent. Catch it to treat the duplicate as “already started”; there is no separate startOnce. Any other failure of that write rejects with StorageWriteError — including a start refused on the fence twice with sequence 0 still absent, whose cause is StartFencedOutError (a newer token’s holder wrote nothing and may be dead, so the start stays retryable; L20). A minted ULID cannot collide, so start without an executionId pays no extra write and is byte-identical to before. onEvent otherwise stays pre-durability for every later event: a consumer that must act only on durable facts reads them back (the ADR 0058 catch-up shape).

Durable placement (#392). start(flow, input, { computeTarget }) records the compute lane on the execution’s EXECUTION_STARTED anchor (envelope metadata — descriptive, never folded, so replay is untouched), and placementOf(eventStore, executionId) (@loom/workflow-runtime) reads it back in one point-read. TimerService and recoverAbandoned accept a dispatcher and re-drive to that lane instead of resuming in-process; a fork inherits its parent’s lane and fork(…, { computeTarget }) overrides it. A HINT, not a pin: PlacementDispatcher falls back for an unregistered target (and warns once), because a retired lane must not strand the executions that named it.

What is stuck, and is anything watching it. orphanCensus(runtime, { source, leases?, buckets? }) (@loom/workflow-runtime) is the READ-ONLY pass beside the acting ones: no predicate, no writes, no policy — a predicate is what a census must not need, since the question it answers is whether YOURS is right. It bands running/pending executions by age (cumulative, with a sample of ids) and, given a lease store, classifies each as held / expired / none. none is the one to watch: a stuck execution nothing ever leased cannot be recovered from evidence at all. Clocks: lease expiry is judged on options.now ?? leases.storeNow?.() ?? Date.now() (the store’s own clock when it has one), while execution ages use the wall clock or now. LeaseStore.storeNow?() is optional since SPI 2.1.0 and implemented by PostgresLeaseStore (DB time), so a census run from a skewed host still judges leases on the clock that stamped them.

Batch and steal (LeaseStore SPI 2.2.0, ADR 0130 D11). LeaseStore gains the OPTIONAL acquireMany(ids, owner, ttlMs, now?, options?) (the leases granted; an id another owner holds is absent), renewMany(ids, owner, ttlMs, now?) (returns the HELD set, the ids still owned after the call, never its input, and never resurrects a released or taken lease) and releaseMany(ids, owner), and acquire (single id ONLY: acquireMany rejects steal with TypeError, a fence belongs to one execution) takes { steal: { fence } } (a positive integer; the proof is shape-only, so pass a fence actually won, never an explicit now in production): a lease another owner holds UNEXPIRED may be taken only with a conditional-fence proof (the fence won by acquireFenceIf); any other steal value throws TypeError and changes nothing, and the displaced owner’s next renew reports it lost. InMemoryLeaseStore, SqliteLeaseStore and PostgresLeaseStore implement all of it (Postgres: one multi-row statement per call, ANY($ids), database clock, and fillfactor / autovacuum options on the leases table); the kit runs the batch cases for a store that implements any batch method (then all three are required) and forces them with spiVersion: "2.2.0", so a 2.1.0-shaped store passes with no options. sqliteStorage() and postgresStorage() return a co-located leaseStore next to eventStore (accessor only, lazy and closed with the event store on SQLite; defineConfig does not read it yet).

*Heartbeat lease keeper (ADR 0130 D11 S7b/S7c; wired into every driver through WorkflowRuntimeDeps.heartbeat { leaseStore, ttlMs?, renewEveryMs?, onStoreError?, nodeId? } (HeartbeatOptions, with acquire?: "eager" | "lazy" (default lazy) and lazyGraceMs?): start, resume, resumeAs, restoreFromState, the send/notify winner, a continue-as-new successor and LocalWorkerRuntime take a lease BEFORE the fence (eagerly, except that a fresh start is lazy by default) and release it awaited at the suspend/terminal commit; another live owner = LeaseHeldError (stand down, no fence); a lost lease aborts the run with LeaseLostError, appends nothing and gates every dispatch and append; runtime.leaseKeeper, runtime.acquireDriverLease(id), runtime.closeHeartbeat()).* new LeaseKeeper({ store, nodeId, ttlMs?, renewEveryMs?, onLost, ... }) (@loom/workflow-runtime) is the one-per-node form of renewWhileHeld: acquire(id) / release(id) are group-committed, one batched renew runs per tick (renewMany when the store has it, single-id calls otherwise), validNow(id) is the no-I/O gate (a local monotonic deadline of lastRenewSent + ttl - ttl/6), and onLost(id, LeaseLostError) fires on a renew that no longer holds the id or when the deadline passes with no confirmed renewal. A store error opens a one-TTL quiet period (LeaseStoreQuietError); every acquire/release store call is bounded by storeTimeoutMs (default min(ttl/6, 1000) ms, LeaseStoreTimeoutError). A caller-chosen start that finds a lease but no sequence 0 (a ghost) rejects with the retryable StartLeaseHeldError. onStoreError: "continue" (S7d) lets drivers run through a lease-store outage (no deadline abort; a taken-over lease still stops them; a new driver whose acquire hits the outage runs unleased) and marks the keeper’s owner with HEURISTIC_OWNER_SUFFIX (isHeuristicOwner(owner)), so its expired leases are never evidence on any node. Lease consumers (S7d): an unexpired lease vetoes isAbandoned on the poll and on recoverAbandoned; with heartbeat on in stop mode over a cluster-visible store the TimerService refusal re-drive runs by default on an expired lease (an absent lease is never evidence); the internal redriveAbandoned (not exported) takes { conditional: true } (the runtime’s internal claimRecovery appends the EXECUTION_RECOVERED marker with appendIfUnchanged before the fence; RunOptions.recoveryClaimed stops the resume from writing a second); collectGhostLeases(runtime, { limit? }) deletes expired leases that have no stream (the poll runs it, bounded, once per TTL); a lease-store read error fails closed as LeaseEvidenceError (a one-TTL quiet period follows recovery).

Retiring an abandoned branch. Retention never reclaims an execution while a live descendant branch still has events (R5.3) — and a branch nobody answered is suspended forever, so a forked parent returns blocked-by-branch on every sweep, permanently. retireAbandonedBranches(runtime, registry, parentId, { isAbandoned }) (@loom/workflow-runtime) walks that parent’s direct children, offers the non-terminal ones to your predicate, and calls cancel() on the ones it claims — a durable EXECUTION_CANCELLED, not a deletion, so why the branch ended survives. Ordinary retention then collects the branch on its own TTL and the parent behind it. isAbandoned is REQUIRED, for the same reason recoverAbandoned’s is: a branch parked on a human gate is someone’s open question, not garbage. The summary reports what it left alone.

Fail-closed execution authorization (R3, 1P half). executionAuthorizer gates start/resume/cancel/loadState, but only for a call that carries a principal: a call with none skipped it, so an edge that forgot to attach one was wide open. WorkflowRuntimeDeps.executionAuthz: { requirePrincipal: true } (set by security.strict; strict: { requirePrincipal: false } opts out) makes the authorizer see the principal-less request (principal: undefined) so the policy decides. Off (default) is byte-identical. The kernel’s own principal-less machinery — TimerService (wakes, schedule starts), recoverAbandoned, retireAbandonedBranches, the superviseChildren child start and parent resume — presents runtime.systemOptions() ({ system }, or {} when the mode is off): an identity-compared per-instance grant, not a principal, never recorded and never reaching an effect’s authorization. A clone, JSON copy or another runtime’s grant is refused. It is an in-process capability: never hand it to request-derived code. Observers that read state (orphanCensus, reconcileTimers, liveQuery) take runtime.systemStateReader(). executionAccessPolicy(options) (@loom/workflow-runtime) is the reference ExecutionAuthorizer: per-op rules ("owner" | "tenant" | "any" | "deny"; defaults start: "any", the rest "tenant"), allowWorkflows, requireTenant, allowAnonymous (default false), resolveOwner (legacy logs only), unknownOwner, the op actAsOwner (default deny) and the op send (default "tenant": runtime.send, the only writer of an execution’s inbox, is judged against the OWNER of the chain head like a resume, ADR 0110). The principal is durable (R3k, ADR 0111): the kernel records it on EXECUTION_STARTED and hands the owner to the policy as ExecutionAuthzRequest.owner (read from sequence 0), so there is no in-process owner table (ownerOf / adopt / maxTracked are gone) and ownership survives restarts; the internal paths bypass only the lifecycle gate, and the flow’s EFFECTS run as the recorded owner on every path (options.principal is only the lifecycle caller). A principal-less supervisor child has no owner. PrincipalCodec / PrincipalResolver / security.principalCodec / security.crypto control how the record is written and rebuilt; a fork acts as the forker (fork(…, { actAs: "owner" }) is op actAsOwner). fork (query parent + start child), restoreFromState (resume) and acquireFence (cancel / start) take { principal, system } and are gated. LocalWorkerRuntime holds NO system grant by default: a dispatched package acts as ExecutionPackage.principal (Dispatcher SPI 1.2.0); new LocalWorkerRuntime(runtime, { system }) is the explicit opt-in for kernel re-drive targets (runsAsSystem; createWorkerServer then requires authorize). InProcessA2ABus (options.principal / DispatchOptions.principal), executeGraph (options.principal), handleRpc (CarrierOptions.resolvePrincipal on every execution.* method) and createA2AServer (resolvePrincipal) attach the caller; without one the call is anonymous and refused under strict.

Retention and compaction of an encrypted log (ADR 0022 Amendment). LogLifecycle.compact throws UnsafeCompactionError when the event store encrypts at rest (exposes keys) and the snapshot store does not share that same KeyStore — the checkpoint is the DECRYPTED fold, so a plaintext one would survive a key shred. applyRetention / evaluate return "unsafe-archive" (a RetentionOutcome) when a configured archive does not share the source’s keys. Both refuse before anything is read, copied or truncated. A compacted execution is judged on its snapshot plus the surviving suffix, so it is reclaimable.

Snapshot pruning (ADR 0134, off by default). LogLifecycle.pruneSnapshots(executionId, { keepLatest?, keepSequences? }) deletes superseded snapshots in one statement (SnapshotStore.pruneSnapshots, SPI 1.2.0) and resolves with the count. It never prunes the latest keepLatest (default 1), a compacted log’s checkpoint and everything at or below it, a log compacted through its tip ([0]), a compactionExempt execution, or keepSequences; so every fork point and every fold is unchanged (a fork may fold more events). The runtime never calls it on its own. { dryRun: true } resolves with the count a real call would delete and deletes nothing, through the same selection code; it needs the optional SnapshotStore.listSequences (SPI 1.3.0, the ADR 0134 amendment) and over a store without it refuses with SnapshotListingUnsupportedError (an exact count or none). { policy } (ADR 0145, a retentionPolicy() value) supplies keepLatest from policy.snapshots; an explicit keepLatest wins.

Compaction archives what it removes (ADR 0138, undefined 1.1.0). LogLifecycle.compact appends the exact envelopes it removes to an ArchiveStore, THEN saves the checkpoint, THEN truncates (a crash leaves a duplicate, never a gap), and REFUSES with CompactionArchiveRequiredError when none is configured (PlaintextArchiveError when the log seals and the archive does not seal under the SAME KeyStore). defineConfig supplies an archive for kind: "memory", sqliteStorage() and postgresStorage(); a durable custom storage sets storage.archiveStore or durability.retention.compactionArchive (durability.retention.tenant makes compact refuse unless the event store and the archive are visibly scoped to that tenant). readRange, replay, resume and every sweep never read the archive.

interface ArchiveStore {            // @loom/event-runtime
  append(executionId: string, segment: ArchiveSegment): Promise<void>;   // append-only, contiguous; an exact repeat is a no-op; else ArchiveConflictError
  headers(executionId: string): Promise<ArchiveSegmentHeader[]>;         // { from, through, count, digest, incarnation, kind?, senderDigest? }
  listExecutions(opts?: { cursor?: string; limit?: number }): Promise<{ executionIds: string[]; nextCursor?: string }>;
  read(executionId: string, range?: { from?: number; through?: number }): Promise<ArchiveSegment[]>;
  deleteExecution?(executionId: string): Promise<void>;                 // the retention path only
  replaceSegments?(executionId, expected: ArchiveSegmentHeader[], next: ArchiveSegment[]): Promise<void>;   // SPI 1.1.0: atomic CAS (erasure's sender redaction)
  close(): Promise<void>;
}
class InMemoryArchiveStore; class SqliteArchiveStore { constructor(path: string) }; class PostgresArchiveStore;    // archive_segments
class TenantScopedArchiveStore implements ArchiveStore { constructor(inner: ArchiveStore, tenant: string) }       // ArchiveTenantMismatchError
class EncryptingArchiveStore implements ArchiveStore { /* @loom/plugin-crypto; sealed under the log's KeyStore + subjectOf; senderSubjectOf? */ }
const ARCHIVE_STORE_SPI_VERSION = "1.1.0"; function runArchiveStoreConformance(...); function runArchiveStoreDecoratorConformance(...)
// LogLifecycle: readArchived(id, range?) (digest-verified, ArchiveDigestError), readWithArchive(id), archiveCoverage(id) (UnarchivedRange[] { from, through, why }),
//   readLegacyCheckpoints(id), readReservedWithArchive(streamId) ({ events, lateRuns }), reservedArchiveCoverage(streamId), readReservedArchive(streamId).
//   ArchiveIncompleteError for a range only a pre-0138 checkpoint remembers (unless allowIncomplete); ArchiveGapError; ArchiveIncarnationError (reused id).
// sweepUnpinnedContent({ archiveStores, acknowledgeUnarchivedLogs? }): an ERASING pass refuses (ArchiveStoresRequiredError, reason "uncovered") unless the archives cover EVERY compacted log;
//   acknowledgeUnarchivedLogs: true is the explicit opt-in for logs whose history is already gone (reported in unarchivedAcknowledged).
// hashChainedEventStore(inner, { allowCompaction: true }) + verifyChain(id, { archived }) verifies a chain across a compaction.

Reserved streams archive too (ADR 0139, undefined 2.6.0). trimInbox, trimTopicStream, trimQuarantine and the terminal deleteInbox MOVE the envelopes into the same archive (keyed by the stream id) and refuse without one (ReservedStreamArchiveRequiredError; a hand-built runtime with the inbox on and no inboxArchive is refused at construction with InboxArchiveRequiredError unless its store is in-memory or it says inboxArchive: "none", as kernel.inbox.archive: "none" does in defineConfig). The terminal delete is bounded by the optional EventStore.deleteThrough(id, through); a store without it refuses (ReservedStreamDeleteUnsupportedError). EraseCoordinator (archives option, wired by defineConfig) deletes a subject’s copies from a plaintext archive (ArchivedCopiesRemainError refuses before the shred), and a sealing deployment seals message senders under an opaque per-sender handle (SenderErasedError blocks an erased sender until app.reinstateSender(actor, tenant?, { by }); SenderMasterLostError, SenderEraseIncompleteError, SenderRedactionUnsupportedError; app.recoverArchiveRedactions() restores a staged redaction). KeyStore.getOrCreate MUST be an atomic create-if-absent.

Compaction integrity (ADR 0106). The checkpoint is named for its execution (a blank one used to send every event a later resume appended to execution ""), a log compacted before is folded ON its checkpoint (CompactionCheckpointMissingError when that checkpoint is gone, nothing touched), and new LogLifecycle(events, snapshots, { pinnedEventTypes }) keeps the app’s pinned-kind events in place through compaction (EventStore.truncatePrefixKeeping, SPI 1.5.0). A store without that method can never compact past the first kept event, and onWarn says so. Compaction never removes the tip when it keeps events, so a checkpoint is always the log’s highest missing sequence (the tip rule). Every kernel-written kind and every non-string is refused in pinnedEventTypes. compact consults the pinned predicate only when pinnedEventTypes is empty (then a pinned execution is left uncompacted); with kinds declared, a pinned execution compacts around them. compactionExempt (consulted always) keeps a whole log uncompacted. pinByEventTypes: true (also durability.retention.pinByEventTypes) makes evaluate / applyRetention pin an execution whose log holds one of pinnedEventTypes, so the kinds are declared once instead of again in a pinByEventKinds predicate (ORed with pinned; off by default; true with no kinds throws); lifecycle.pinnedEventTypes is the read-only getter for the configured kinds. compact refuses reserved control streams. shredSubject places its tombstone after the folded tip (pass a snapshotStore, raw or the app’s encrypting one, tenant-scoped or not; required on a compactable store) and is retry-safe, and EraseCoordinator takes a snapshotStore so a compacted terminal execution is not read as live. A snapshot whose non-blank identity names another execution is refused (SnapshotIdentityMismatchError); a blank one is filled. reconstructAt refuses a position inside compacted history (CompactedHistoryError). fork of a compacted parent is served exactly — every event between the nearest snapshot and the fork point survives, or a snapshot sits exactly at it (a [0] log’s checkpoint is forkable: the range is the folded tip) — or refused with CompactedForkPointError (reason, forkableFrom..forkableThrough) and nothing written (ADR 0107). LogLifecycle.blankStreamCensus() / rehomeBlankStream({ apply, assign }) (loom repair blank-stream) re-home the events the pre-0106 bug wrote to execution "" where a snapshot proves their owner (one batch per execution, under a new fence when the execution is fenced; failed lists what moved since the census), journal EVENTS_REHOMED, and list the rest with candidates rather than guess; "" is reserved (isReservedStream("")).

*Holding a topic whose head is sealed (ADR 0130 residuals D9).* runtime.quarantineInbox(executionId, { topic, reason? }, { principal }) (op quarantine) and runtime.releaseInbox(executionId, { topic, messageId, sender?, disposition? }, { principal }) (op release, or discard for disposition: "discard") are the operator verbs of the per-topic hold. Both default to "deny" in executionAccessPolicy like discard, REQUIRE a principal (refused with ExecutionDeniedError before anything is read), and refuse with InboxQuarantineRefusedError (reason: not-parked | contended | invalid | not-sealed | checkpoint-unreadable-log-compacted, verb: quarantineInbox | releaseInbox | fork) with nothing appended. quarantineInbox copies the topic’s sealed head into the reserved __loom_quarantine__:<chainId> stream FIRST (deterministic id quarantine:<chainId>:<key>:<inboxSeq>; sequence 0 of that stream is a payload-free anchor), then journals INBOX_QUARANTINED under the park’s conditional fence; the topic is then held for every node, in original order, and a node that can open the copy releases it by itself (INBOX_RELEASED { auto: true } + MESSAGE_CONSUMED at the original inboxSeq in ONE batch; WorkflowRuntimeDeps.inboxAutoRelease / kernel.inbox.autoRelease, default true). WorkflowRuntimeDeps.inboxQuarantineMax / kernel.inbox.quarantineMax (default 256, ceiling 1024) bounds the held copies per chain; runtime.inboxAutoReleaseOn / runtime.inboxQuarantineLimit report the node’s settings. On a node that cannot open the owner’s SEALED checkpoint the two verbs (only they) fold the owner from the complete log; a compacted log is checkpoint-unreadable-log-compacted (run the verb on a capable node). That log is read PAST a seal whose covers lists only MESSAGE_CONSUMED.payload[.…] (an opaque ciphertext value the fold copies and never interprets; never decrypted, no key asked); any other covered field is still the ordinary denial. The state so folded is transient: used to decide the verb, never journaled, returned, cached or snapshotted. A held topic no node here can open is reported as QuarantineHeldError (a SealedPayloadUnreadableError). Types: QuarantineInboxInput/Options/Result, ReleaseInboxInput/Options/Result.

Collecting the registry. After retention reclaims a branch, two rows outlive it. LogLifecycle forgets the fork edge as it deletes the execution (optional removeLineage — dead edges otherwise make every future sweep walk them). pruneBranchRefs(eventStore, registry, { pageSize?, limit?, protect? }) (@loom/workflow-runtime) pages over listRefs and deletes refs whose execution has no events — a dangling ref is not merely untidy, since promoting one leaves main naming an execution nobody can read. It takes NO predicate: unlike “is this branch abandoned”, “the execution is gone” is a fact. main is protected by default and reported as dangling instead.

Cancellation, deadlines & bounded effects (2.0, K8). A step’s timeoutMs bounds a single effect; a flow’s deadlineMs (see FlowDefinition) bounds the whole run against the logged EXECUTION_STARTED timestamp (never reset by recovery). Breach — or handle.cancel() / ExecutionRuntime.cancel() — aborts in-flight effects via EffectContext.signal and records a kernel-driven EXECUTION_CANCELLED.

The kernel ABI (2.0, K1). The syscall surface is named and versioned; a conformance test pins it.

const KERNEL_ABI_VERSION: string;   // semver, currently "1.3.0" — MINOR bumps: 1.1.0 added sleep/sleepUntil (T1), 1.2.0 gave decide's decider a DecideScope (L30), 1.3.0 added receive/select/continueAsNew/negotiateBudget and deprecated ui (ADR 0114)
const KERNEL_SYSCALLS: readonly { name: string; kind: "call" | "accessor"; since: string; summary: string; deprecated?: boolean }[];
const KERNEL_ABI: { version: string; syscalls: typeof KERNEL_SYSCALLS };
// syscalls: execute · run · decide · parallel · suspend · sleep · sleepUntil · emit · stream · ui (deprecated) · receive · select · continueAsNew · negotiateBudget

Retention policy (ADR 0145). A plain, frozen, validated value the app owns and hands to the retention passes; it carries no behaviour and schedules nothing. retentionPolicy() and assertRetentionPolicy() throw RetentionPolicyError (a RangeError) for an unknown key, terminalTtlMs that is not an integer >= 0, snapshots.keepLatest below 1, content.unpinnedAfterMs not an integer > 0, or content.unpinnedAfterMs < terminalTtlMs.

interface RetentionPolicy { terminalTtlMs: number; snapshots?: { keepLatest: number }; content?: { unpinnedAfterMs: number } }
function retentionPolicy(spec: RetentionPolicy): Readonly<RetentionPolicy>            // validates, then freezes
function assertRetentionPolicy(p: unknown): asserts p is RetentionPolicy
class RetentionPolicyError extends RangeError

// LogLifecycle (a policy is re-validated on every call)
evaluate(executionId: string, policy: RetentionPolicy, now: number): Promise<RetentionVerdict>        // judges, writes nothing
applyRetention(executionId: string, policy: RetentionPolicy, now: number): Promise<RetentionOutcome>  // archive (if configured), then delete
pruneSnapshots(executionId: string, options?: PruneSnapshotsOptions): Promise<number>                 // the count deleted (or, with dryRun, that would be)
interface PruneSnapshotsOptions { keepLatest?: number; policy?: RetentionPolicy; keepSequences?: readonly number[]; acceptUnattributedBlankStream?: boolean; dryRun?: boolean }
type RetentionOutcome = "reclaimed" | "kept" | "not-terminal" | "blocked-by-branch" | "pinned" | "unknown" | "unsupported" | "unsafe-archive";
type RetentionVerdict = Exclude<RetentionOutcome, "reclaimed"> | "reclaimable";

Signed approvals (ADR 0117). answerApproval builds the resume event that answers the gate an execution is parked on, echoing the gate’s viewDigest; signResume signs it for ONE park; signedResumeAuthenticator verifies it (HMAC-SHA-256, secrets >= MIN_SECRET_BYTES = 16, exp required). It covers the whole event, the execution id and the parking sequence, so a signature cannot be replayed at another execution, a fork of it, or a later park of the same gate. viewDigest is v2: + SHA-256 over canonical JSON and detects staleness; it is not a MAC and does not say WHO approved.

function answerApproval(state: Pick<ExecutionState, "suspendedOn" | "executionId">, decision: Record<string, unknown>): ResumeEvent   // throws when the execution is not parked at a gate
function viewDigest(view: unknown): string                                              // "v2:<sha256 hex>"; TypeError for a value canonicalJson refuses
function approvalViewDigest(gateKey: string, toolId: string, input: unknown): string    // the digest a tool-call approval gate declares
function signResume(event: ResumeEvent, signer: { actor: string; secret: string; exp: number; executionId: string; parkedAtSequence: number; waitRegisteredAtSequence?: number }): ResumeEvent
function signedResumeAuthenticator(options: SignedResumeOptions): ResumeAuthenticator
interface SignedResumeOptions {
  gates: readonly string[];   // gate names with approval semantics; every other event is `not-applicable`
  keys: Readonly<Record<string, string | readonly string[]>> | ((actor: string) => string | readonly string[] | undefined);   // several secrets = rotation
  authorize?: (actor: string, event: ResumeEvent) => boolean;                                  // runs after the signature verifies
  requireActor?: (actor: string, principal: PrincipalChain | undefined) => boolean;            // default: must equal the call's principal actor, when it has one
  now?: () => number;                                                                          // clock for `exp`; default Date.now
}
const SIGNED_RESUME_VERIFIED_BY = "hmac-sha256";
const MIN_SECRET_BYTES = 16;

onDurableEvent (ADR 0147). WorkflowRuntimeDeps.onDurableEvent is called with an event after its durable write has resolved, so it NEVER reports an event the log does not hold (a lost, rejected or fenced write is not delivered). At most once: a process that dies after a write, or a lost acknowledgement, never delivers it, so read the log when you need every event. In order per writer, not per execution: under a delayed acknowledgement a later sequence can arrive first, so order by sequence. Asynchronous: it lags the fold and a slow store builds a backlog; an async consumer is not awaited. Only this runtime’s writes: another process, LogLifecycle and crypto-shredding are not reported. A throw or rejection goes to onWarn (at most once a minute) and never reaches the run. Unset, the write path does no extra work. onEvent, by contrast, fires BEFORE the durable write.

Inbox, cron, re-drive, in-doubt.

type InboxSource = (chainId: string, topic: string, afterSeq: number, limit: number) => Promise<InboxMessage[]>;   // messages with inboxSeq > afterSeq, oldest first
function createInboxSource(store: EventStore): InboxSource                  // the default reader: bounded range reads over the runtime's own EventStore

interface CronSpec { minutes: Set<number>; hours: Set<number>; daysOfMonth: Set<number>; months: Set<number>; daysOfWeek: Set<number>; timeZone?: string }   // timeZone absent = UTC
function parseCron(expr: string, timeZone?: string): CronSpec               // 5 fields (min hour dom mon dow); throws on a bad expression or unknown IANA zone
function nextOccurrence(spec: CronSpec, afterMs: number): number            // pure and deterministic; DST gaps shift forward, a repeated hour runs once (first occurrence)

function scheduleRedrive(store: TimerStore, executionId: string, attempt: number, now: number, backoff?: RedriveBackoff, minWakeAt?: number): Promise<TimerRecord>   // writes the due-later row that re-drives a contended resume
interface RedriveBackoff { baseMs?: number; capMs?: number; random?: () => number }   // defaults 200 / 30 000 / Math.random; full jitter
function redriveDelayMs(attempt: number, backoff?: RedriveBackoff): number
const DEFAULT_MAX_REDRIVE_ATTEMPTS = 8;
class RedriveExhaustedError extends Error { executionId: string; attempts: number }   // still contended after the attempts; left to recover()

function inDoubtDecisionId(stepId: string, attempts: number): string       // the reserved "__loom_indoubt__:<stepId>:<attempts>" decision id of an in-doubt verdict

Continue-as-new errors (ADR 0109). All carry retryable = false; ctx.continueAsNew needs kernel.features.continueAsNew.

class ContinueAsNewUnavailableError extends Error                                                       // the feature is off
class ContinueAsNewRefusedError extends Error { executionId: string; reason: string }                   // precondition fails or the carry is unusable; nothing appended
class CarryTooLargeError extends ContinueAsNewRefusedError { bytes: number; maxCarryBytes: number }    // DEFAULT_MAX_CARRY_BYTES = 1_048_576
class ContinuationConflictError extends Error { predecessorId: string; successorId: string; foundContinuedFrom: string | undefined }   // a squatter at the successor id
class ForkPastContinuationError extends Error { executionId: string; atSequence: number; continuedAtSequence: number | undefined; successorId: string | undefined }   // fork the successor instead
class ChainHopLimitError extends Error { executionId: string; hops: number; reached: string }           // head walk ran out of maxChainHops (DEFAULT_MAX_CHAIN_HOPS = 8)
class ReservedSuccessorIdError extends Error { executionId: string }                                    // a caller-chosen id of the reserved "<x>~<digits>" shape
interface ExecutionHandle {
  executionId: string;
  waitForCompletion(): Promise<ExecutionState>;
  waitForSuspend(): Promise<ExecutionState>;  // resolves on a park; rejects ExecutionTerminatedError if the run completes/fails/is cancelled/continues as new
  getState(): ExecutionState;
  cancel(reason?: unknown): void;   // live-only — aborts in-flight effects, records EXECUTION_CANCELLED
}

type FlowRunner<TIn, TOut> = (ctx: WorkflowContext, input: TIn) => Promise<TOut>;
interface RegisteredFlow<TIn, TOut> { definition: FlowDefinition<TIn, TOut>; runner: FlowRunner<TIn, TOut>; }
interface RunOptions   { executionId?: string; /* caller-chosen ⇒ start-once (ADR 0103) */ computeTarget?: string; deadlineMs?: number; principal?: PrincipalChain; system?: SystemGrant; parentExecutionId?: string; /* …plus kernel-internal fence / recovery / lineage grants */ }
interface ResumeEvent  { eventType: string; payload: unknown; waitId?: string; discardLegacyAnswers?: { stepId: string; count: number } }
interface ForkOptions  { branchName?: string; executionId?: string; computeTarget?: string; actAs?: "owner"; principal?: PrincipalChain; system?: SystemGrant; }
interface ForkResult   { executionId: string; parentExecutionId: string; forkSequence: number; branchName: string; state: ExecutionState; }

Also exported (resumability, for server→client handoff): serializeExecution / deserializeExecution, serializeProjections / deserializeProjections, and ProjectionSnapshot.


@loom/orchestration — parent/child executions

Built from ctx.decide + ctx.suspend — no syscall and no new event type. A spawn intent is a recorded DECISION, which is what makes recovery never start a second child.

function awaitChild<TOut>(ctx, spec: { key: string; flowId: string; input: unknown; maxDepth?: number;
  grants?: readonly string[]; expiresAt?: number; detached?: boolean }): Promise<TOut>
function awaitChildren<TOut>(ctx, group: { key: string; maxDepth?: number; children: readonly { flowId: string; input: unknown;
  grants?: readonly string[]; expiresAt?: number; detached?: boolean }[] }): Promise<ChildResult<TOut>[]>
function awaitAll<TOut>(ctx, group: { key: string; maxDepth?: number;
  children: readonly { flowId: string; input: unknown }[] }): Promise<TOut[]>   // all-or-nothing: throws childFailed
type ChildResult<TOut> = { status: "completed"; output: TOut } | { status: "failed"; error: string } | { status: "cancelled"; reason?: string }   // ctx is Pick<ExecutionContext, "executionId" | "decide" | "suspend">
function superviseChildren(deps: SupervisorDeps): ChildSupervisor  // wire into onEvent
interface SupervisorDeps { runtime: WorkflowRuntime; flows: Record<string, RegisteredFlow>; onError?; delegation?: { secret: string }; limits?: SpawnLimits; redrive?: RedriveWiring; tuning?: Partial<SupervisorTuning> }
interface ChildSupervisor { onEvent(event: EventEnvelope): void; recover(options?: { source?: ExecutionEnumerable; pageSize?: number }): Promise<RecoverSummary>; parkedOnChild(executionId: string): Promise<boolean>; size() }
function fanout$<TIn, TOut>(ctx, baseStep: StepDefinition<TIn, TOut>, items: readonly TIn[],
  options?: { limit?: number }): Promise<TOut[]>                  // one step per item, bounded concurrency
const CHILD_DONE = "CHILD_DONE"; const CHILD_FAILED = "childFailed"   // userspace events the fold skips
function childIdFor(parentId: string, key: string): string
function parentOf(childId: string): string | undefined; function depthOf(executionId: string): number

// R9 — the autonomy envelope for spawning. Both bounds are off when unset.
interface SpawnLimits { maxDepth?: number; maxFanOut?: number }    // deepest child (root 0, child 1); children per group
function boundOrchestration(limits: SpawnLimits): { awaitChild; awaitChildren; awaitAll }  // parent-side: throws BEFORE the spawn decision is recorded
// SupervisorDeps.limits?: SpawnLimits — the backstop: a refused spawn starts NO child, reports via onError, and
// resumes the parent with a failed child (awaitChild throws childFailed; awaitChildren returns failed entries).
class SpawnLimitExceededError extends Error { limit: "maxDepth" | "maxFanOut"; max; actual; readonly retryable: false }

// ADR 0118 D3 — retention pin predicates (PinPredicate = (executionId) => boolean | Promise<boolean>); compose with an app predicate by ||, never replace it
function pinParkedChildren(supervisor: Pick<ChildSupervisor, "parkedOnChild"> | (() => Pick<ChildSupervisor, "parkedOnChild"> | undefined)): PinPredicate
                                            // pins a finished child whose parent is still parked on it; pass a function when the supervisor is created after the config
function awaitedByParent(store: Pick<EventStore, "read" | "head" | "readFrom" | "readRange">, options?: {
  snapshots?: { loadLatest(executionId: string): Promise<{ sequence: number; state: unknown } | null> };   // only for a compacted parent whose lifecycle events are gone
  onWarn?: (message: string) => void;       // "cannot decide, keeping the child"; default console.warn
}): PinPredicate                            // pins a child whose recorded parent (sequence 0) is still live; FAILS SAFE: any doubt pins, and onWarn says why

@loom/saga — compensation without a syscall

function createSaga(ctx: Pick<ExecutionContext, "run">): Saga
function withSaga<T>(ctx: Pick<ExecutionContext, "run">, body: (saga: Saga) => Promise<T>): Promise<T>
interface Saga {
  run<TIn, TOut>(step: StepDefinition<TIn, TOut>, input: TIn, compensator?: StepDefinition<TOut, unknown>): Promise<TOut>  // compensator gets the forward OUTPUT
  compensate<T>(step: StepDefinition<T, unknown>, input: T): void   // register a compensator with a custom input
  rollback(): Promise<void>       // reverse order; each compensator is a replay-guarded ctx.run step; idempotent
  readonly pending: number
}
class SagaCompensationError extends Error {   // a compensator failed; the rest still ran
  readonly compensationErrors: readonly unknown[]   // run order; out of withSaga, `cause` = the ORIGINAL error
}
// SuspendError and runtime signals (cancel, lost lease/fence, divergence, storage) propagate unwrapped
// ADR 0041 — the plan's `ctx.compensate` syscall and COMPENSATION_* events proved unnecessary

@loom/fair-scheduler — per-tenant fairness

function createFairScheduler(config?: { concurrency?: number; onEvent?: (event: SchedulerEvent) => void }): FairScheduler
// `concurrency` defaults to Infinity: gating disabled, zero overhead.
interface FairScheduler extends Scheduler {
  schedule<T>(task: (signal: AbortSignal) => Promise<T>,
    opts?: { tenant?: string; weight?: number; priority?: Priority; signal?: AbortSignal; label?: string }): Promise<T>
}   // a tenant's weight is set by its first queued task
// deficit round-robin: one noisy tenant cannot starve the others (ADR 0038)

Replay & storage

@loom/replay-vm — deterministic replay

function foldEvents(events: EventEnvelope[], initial?: Partial<ExecutionState>): ExecutionState

class ReplayVM {
  constructor(events: EventEnvelope[], initial?: Partial<ExecutionState>)
  applyEvent(event: EventEnvelope): void
  getState(): ExecutionState
  hasCompleted(stepId: string): boolean
  hasFailed(stepId: string): boolean
  getOutput(stepId: string): unknown
}

foldEvents is a pure left-fold — the entire basis of durability, resume, and fork. (It uses copy-on-write structural sharing; see ADR 0015.)

Event-schema ABI (2.0, K5). The event log is the kernel’s on-disk ABI; the handled event set is versioned and pinned.

const EVENT_SCHEMA_VERSION: string;      // semver
function knownEventTypes(): string[];    // the sorted set the fold understands (pinned by a governance test)

@loom/event-runtime — the durable log

interface EventStore {
  append(envelope: EventEnvelope): Promise<void>;   // rejects DuplicateSequenceError / DuplicateEnvelopeIdError (id uniqueness is per execution)
  read(executionId: string): Promise<EventEnvelope[]>;
  readFrom(executionId: string, fromSequence: number): Promise<EventEnvelope[]>;
  subscribe(executionId: string, handler: (event: EventEnvelope) => void): Promise<() => void>;   // cross-node delivery can lose a wake-up; the log stays complete
  close(): Promise<void>;

  // Optional — a decorator must forward, override or refuse each (see installOptional below)
  appendBatch?(envelopes: readonly EventEnvelope[]): Promise<void>;   // SPI 1.3.0: ≡ append per envelope, atomic PER EXECUTION, one round-trip; use appendBatchOf(store, envs)
  readRange?(executionId: string, fromSequence: number, toSequence: number): Promise<EventEnvelope[]>;   // [from..to] inclusive; use readRangeOf(store, id, from, to)
  head?(executionId: string): Promise<number | null>;   // the tip, without reading the log — use headOf(store, id)
  acquireFence?(executionId: string): Promise<number>;   // K9.4: bump + return the monotonic token; later appends with a missing/lower metadata.fence throw StaleFenceError
  acquireFenceIf?(executionId: string, expect: { fence: number; parkSeq: number }): Promise<AcquireFenceIfResult>;   // SPI 2.1.0, ADR 0118: acquire only if the park is still unanswered
  appendIfUnchanged?(executionId: string, event: EventEnvelope | null, expect?: TipObservation): Promise<AppendIfUnchangedResult>;   // SPI 2.2.0: append iff head + fence are as observed; event === null only observes
  storeNow?(): Promise<number>;   // SPI 2.4.0: the store's own clock, epoch ms (Postgres: database time)
  sealingCapability?(): SealingCapability | undefined;   // SPI 2.5.0 (RESERVED, no caller yet): what this store seals and therefore opens
  readAtRest?(): AtRestEventReader;   // SPI 2.5.0, ADR 0130 D9: read-only view of envelopes AS STORED, never decrypted
  copyPrefix?(fromExecutionId: string, toExecutionId: string, throughSequence: number, metadata: Record<string, unknown>): Promise<void>;   // fork fast-path
  deleteExecution?(executionId: string): Promise<void>;   // K10.1 retention: all events of an execution
  deleteThrough?(executionId: string, through: number): Promise<void>;   // SPI 2.6.0, ADR 0139 D8: one atomic delete of sequence <= through, INCLUDING 0
  truncatePrefix?(executionId: string, throughSequence: number): Promise<void>;   // K10.4 compaction: delete (0..through]; sequence 0 is preserved
  truncatePrefixKeeping?(executionId: string, throughSequence: number, keepEventTypes: readonly string[]): Promise<void>;   // SPI 1.5.0: as truncatePrefix, but events of a kept type survive
  listExecutions?(): Promise<string[]>;   // every executionId, unordered, reserved `__loom_*` streams included (filter with isReservedStream)
  listExecutionsPage?(opts?: { cursor?: string; limit?: number }): Promise<ExecutionPage>;   // SPI 1.2.0: keyset-paged, Unicode code point order (compareExecutionIds)
}

interface TipObservation { head: number; fence: number; anchor: string }
type AppendIfUnchangedResult = { appended: true } | { appended: false; reason: "moved" | "gone" | "observed"; head: number | null; fence: number; anchor: string | null };
type AcquireFenceIfResult    = { fence: number } | { stale: true; fence: number };
interface ExecutionPage      { executionIds: string[]; nextCursor?: string }
interface SealMarker         { readonly v: 1; readonly cipher: string; readonly covers: readonly string[] }
interface SealingCapability  { readonly cipher: string; readonly covers: readonly string[]; opens(marker: SealMarker): boolean }
type AtRestEventReader       = Pick<EventStore, "read" | "readFrom" | "readRange" | "head">;

// Caller helpers — fall back when the store omits the optional method
function appendBatchOf(store: EventStore, envelopes: readonly EventEnvelope[]): Promise<void>;   // appendBatch, else a sequential append loop (not atomic); empty input is a no-op
function readRangeOf(store: Pick<EventStore, "read" | "readFrom" | "readRange">, executionId: string, fromSequence: number, toSequence: number): Promise<EventEnvelope[]>;
function headOf(store: Pick<EventStore, "read" | "head">, executionId: string): Promise<number | null>;
interface ExecutionEnumerable { listExecutions?(): Promise<string[]>; listExecutionsPage?(opts?: { cursor?: string; limit?: number }): Promise<ExecutionPage> }
function iterateExecutionIds(source: ExecutionEnumerable, pageSize = 1000): AsyncGenerator<string, void, void>;   // pages when listExecutionsPage exists, else listExecutions

class InMemoryAsyncEventStore { constructor(options?: { latencyMs?: number; failAppendWhen?: (e: EventEnvelope) => boolean | string | Error }) }

SqliteEventStore / SqliteBranchRegistry are not exported here — they live in @loom/plugin-sqlite.

Decorators over the SPI:

// Merges concurrent appends ACROSS executions into one appendBatch; an append resolves only once its batch committed
class CoalescingEventStore implements EventStore { constructor(inner: EventStore, options?: CoalescingEventStoreOptions) ; drain(): Promise<void> }
interface CoalescingEventStoreOptions { maxBatch?: number /* default 16 */; maxLingerMs?: number /* default 0: next microtask */ }

// Routes each execution to one of N inner stores; a batch is atomic per execution, not across shards
class ShardedEventStore implements EventStore {
  constructor(shards: readonly EventStore[], options?: ShardedEventStoreOptions)
  setPlan(plan: ShardPlan): void
  migrateExecution(executionId: string): Promise<MigrationResult>   // copy to the destination shard, then remove from the source; fence state does not move
  isBucketDrained(bucket: number): Promise<boolean>                // true when nothing is left on the source (needs enumerable shards)
}
interface ShardedEventStoreOptions { shardOf?: (executionId: string, shards: number) => number /* default FNV-1a; must be stable */; plan?: ShardPlan; placementCacheSize?: number /* default 10,000 */ }
interface MigrationResult { executionId: string; outcome: "moved" | "already-there" | "not-migrating"; events: number }
class ShardSealingMismatchError extends Error

// Explicit bucket -> shard map (ADR 0075); required to change the shard count without re-routing everything
class ShardPlan {
  static uniform(shardCount: number, buckets: number = DEFAULT_BUCKETS): ShardPlan   // DEFAULT_BUCKETS = 4096
  static fromJSON(json: ShardPlanJSON): ShardPlan
  toJSON(): ShardPlanJSON
  get buckets(): number
  get isStable(): boolean
  bucketFor(executionId: string): number
  placementAt(bucket: number): ShardPlacement
  placementFor(executionId: string): ShardPlacement
  migratingBuckets(): number[]
  rebalanced(shardCount: number, options?: { allowShrink?: boolean }): ShardPlan   // marks changed buckets migrating(old -> new); copies nothing
  settled(bucket: number): ShardPlan
  settledAll(): ShardPlan
}
type ShardPlacement = { kind: "settled"; shard: number } | { kind: "migrating"; from: number; to: number };
interface ShardPlanJSON { version: 1; shardCount: number; placements: ({ shard: number } | { from: number; to: number })[] }

// Write-side timing decorator; forwards every optional method the inner store has
class ProfilingEventStore implements EventStore { constructor(inner: EventStore); writeProfile(): WriteProfile; resetProfile(): void }
interface WriteProfile { envelopes: number; roundTrips: number; totalWriteMs: number; maxWriteMs: number; peakConcurrentWrites: number; spanMs: number; envelopesPerSec: number }

Git-style branching — the BranchRegistry:

interface BranchRef      { name: string; executionId: string; tip: number; parentExecutionId?: string; forkSequence?: number; createdAt: number; updatedAt: number; }
interface LineageRecord  { executionId: string; parentExecutionId: string; forkSequence: number; createdAt: number; }

interface ListRefsOptions { limit?: number; after?: string; }             // keyset paging; limit MUST be a positive int; order is Unicode CODE POINT (SPI 2.0.0); `after` may be ANY string, not only a name returned before
interface PromoteOptions  { expect?: string | null; }                    // CAS: the executionId `toName` must name now (null = must not exist)

interface BranchRegistry {
  putRef(ref: BranchRef): Promise<void>;
  getRef(name: string): Promise<BranchRef | null>;
  listRefs(options?: ListRefsOptions): Promise<BranchRef[]>;
  moveTip(name: string, tip: number): Promise<void>;
  promote(fromName: string, toName: string, options?: PromoteOptions): Promise<void>;   // pointer move — the whole "merge" replacement
  deleteRef(name: string): Promise<void>;
  recordLineage(rec: Omit<LineageRecord, "createdAt">): Promise<void>;
  removeLineage?(executionId: string): Promise<void>;   // forget one edge — called by LogLifecycle on delete (optional)
  getLineage(executionId: string): Promise<LineageRecord | null>;
  children(executionId: string): Promise<LineageRecord[]>;
  close(): Promise<void>;
}
class BranchPromotionConflictError extends Error { toName; expected: string | null; actual: string | null }  // a CAS promote lost — nothing moved

Browser-safe subpath: import { InMemoryAsyncEventStore } from "@loom/event-runtime/memory" imports only the in-memory store — no better-sqlite3.

Tenant isolation (2.0, K12.5). TenantScopedEventStore wraps any EventStore and key-prefixes every execution id with the tenant on the way in / un-prefixes on the way out, so a store scoped to tenant A can only ever address keys under A — reading or subscribing across tenants is impossible by construction, not by a filter. Fork stays in-tenant. @loom/snapshot-runtime ships the matching TenantScopedSnapshotStore. Postgres row-level security is an optional hardening layer on top.

class TenantScopedEventStore    implements EventStore    { constructor(inner: EventStore, tenant: string) }
class TenantScopedSnapshotStore implements SnapshotStore { constructor(inner: SnapshotStore, tenant: string) }  // @loom/snapshot-runtime

Writing a store decorator (ADR 0074 Amendment). A decorator must decide EVERY optional SPI method: forward it, override it, or refuse it with a reason. One that silently drops a method takes the capability away from everything above it — four in-repo decorators dropped appendBatch, so Coalescing(TenantScoped(backend)) never batched. installOptional installs a plan whose type maps the whole optional union to REQUIRED keys, so a plan that omits a method (including a future one) does not compile — the kit catches a decorator that uses no plan at all; each method is installed only when the inner store has it (capability probes unchanged), refusals are recorded, and the structural encryption marker (keys) is carried up so LogLifecycle’s key rule and loom doctor still see an encrypting store below. runEventStoreDecoratorConformance (@loom/conformance) is the check.

type OptionalEventStoreMethod = { [K in keyof EventStore]-?: {} extends Pick<EventStore, K> ? K : never }[keyof EventStore];
                                                                                   // derived: every optional key — 16 as of SPI 2.6.0 (see the EventStore listing)
const OPTIONAL_EVENT_STORE_METHODS: readonly OptionalEventStoreMethod[]            // compile-time exhaustive
type DecoratorPlan<S, M extends keyof S> = { readonly [K in M]-?: "forward" | NonNullable<S[K]> | { readonly refuse: string } };
function installOptional<S extends object, M extends keyof S>(target: S, inner: S, plan: DecoratorPlan<S, M>, bindTo?: object): void
function refusalsOf(store: object): ReadonlyMap<string, string>                    // what installOptional recorded as refused
// @loom/snapshot-runtime: OptionalSnapshotStoreMethod ("deleteExecution" | "loadLatestSealed" | "pruneSnapshots" | "listSequences"), OPTIONAL_SNAPSHOT_STORE_METHODS

class Tenant implements EventStore {
  readonly appendBatch?: NonNullable<EventStore["appendBatch"]>;   // …one field per optional method
  constructor(private readonly inner: EventStore) {
    const plan = { appendBatch: (envs) => inner.appendBatch!(envs.map(scope)), head: "forward", /* …every optional method */ }
      satisfies DecoratorPlan<EventStore, OptionalEventStoreMethod>;
    installOptional<EventStore, OptionalEventStoreMethod>(this, inner, plan);
  }
}

@loom/snapshot-runtime — bounded-tail replay

interface Snapshot { id: string; executionId: string; workflowId: string; sequence: number; state: ExecutionState; createdAt: number; }

interface SnapshotStore {
  save(snapshot: Snapshot): Promise<void>;
  loadLatest(executionId: string): Promise<Snapshot | null>;
  loadAt(executionId: string, sequence: number): Promise<Snapshot | null>;
  loadLatestBefore(executionId: string, sequence: number): Promise<Snapshot | null>;   // used by fork()
  deleteExecution?(executionId: string): Promise<void>;           // optional (K10.1 retention)
  loadLatestSealed?(executionId: string): Promise<Snapshot | null>;  // optional (SPI 1.1.0, ADR 0106): at rest, never decrypted
  pruneSnapshots?(executionId: string, range: { after: number; before: number }, keep?: readonly number[]): Promise<number>;
      // optional (SPI 1.2.0, ADR 0134): delete after < sequence < before minus `keep`, one statement; the CALLER (LogLifecycle.pruneSnapshots) decides what is safe
  listSequences?(executionId: string, range?: { after?: number; before?: number }): Promise<number[]>;
      // optional (SPI 1.3.0, ADR 0134 amendment): the stored sequences ascending, one per snapshot, exclusive bounds, no state read; backs pruneSnapshots({ dryRun })
  close(): Promise<void>;
}
class InMemoryAsyncSnapshotStore { constructor(options?: { latencyMs?: number }) }
// Snapshot-size metric (#708): a decorator, zero cost when unwired, never fails or alters a save.
// Outside EncryptingSnapshotStore it measures plaintext state; around the raw store, ciphertext.
class ProfilingSnapshotStore implements SnapshotStore {
  constructor(inner: SnapshotStore, options?: { topN?: number; largeThresholdBytes?: number;
    onLargeSnapshot?(executionId: string, bytes: number): void | Promise<void>; dedupeCapacity?: number;
    sampleRate?: number; sampler?(): number; onMeasurementError?(executionId: string, error: unknown): void })
  profile(): SnapshotSizeProfile   // saves, measured, totalBytes, maxBytes, power-of-two histogram, largest (top N by latest size)
  trackedCounts(): { largest: number; reported: number }
  resetProfile(): void
}

Snapshot codec (ADR 0141, default off, SPI unchanged): encodeState(state, codec) / decodeState(...) store a state as a format byte plus verbatim JSON (0x01) or a checksummed zstd-3 frame (0x02; an unknown byte is a loud SnapshotDecodeError, an oversize declared size SnapshotStateTooLargeError); reads dispatch on the stored format, so old JSON snapshots stay readable. Opt in with sqliteStorage({ snapshotCodec: "zstd" }), postgresStorage({ snapshotCodec: "zstd" }), the stores’ codec option or defineConfig({ durability: { snapshots: { codec: "zstd", lengthLeak?: "accept" } } }); EncryptingSnapshotStore({ sealState: true, codec: "zstd", lengthLeak: "accept" }) compresses BEFORE sealing and so needs the acknowledgment. Needs Node 22.15 or later: SnapshotCodecUnavailableError at construction otherwise (zstdAvailable(), parseSnapshotCodec, assertSnapshotCodecAvailable). loom snapshot show prints a decoded snapshot. Postgres only: migratePostgresStores(pool, { snapshotCompression: "lz4" }) (ADR 0136; fail-soft, a speed trade, not a size one). ProfilingSnapshotStore measures stored (compressed) bytes for a zstd store and plaintext state otherwise.

Frame helpers: FRAME_ZSTD (0x02, the format byte of a zstd-compressed frame) and isFramedState(bytes: Uint8Array): boolean (whether bytes starts with a known frame byte, 0x01 or 0x02).

Browser-safe subpath: @loom/snapshot-runtime/memory.

@loom/plugin-postgres — scale-out storage (2.0)

Postgres implementations of the three store interfaces above — the horizontal-scale backend. Same contracts as the SQLite stores (JSONB payloads, server-side copyPrefix), plus cross-node live subscribe via LISTEN/NOTIFY.

type PgConfig = string | PoolConfig | Pool | PoolLike;   // connection string, pg config, or a shared pool (PoolLike: any structurally compatible pool)
class PostgresEventStore    implements EventStore     { constructor(config: PgConfig, options?: PostgresEventStoreOptions) }   // subscribe() fans across nodes; the options type is not exported by name (fields below)
class PostgresSnapshotStore implements SnapshotStore  { constructor(config: PgConfig, options?: PostgresSnapshotStoreOptions) }   // PostgresSnapshotStoreOptions: { codec?: SnapshotCodecName; migrationLockTimeoutMs?: number; onWarn?: (message: string) => void }
class PostgresBranchRegistry implements BranchRegistry { constructor(config: PgConfig, opts?: { now?: () => number; migrationLockTimeoutMs?: number }) }
class PostgresTimerStore    implements TimerStore     { constructor(config: PgConfig) }   // timers live beside the log; INSERT ... ON CONFLICT DO NOTHING keeps scheduling idempotent per (executionId, wakeAt)
function migratePostgresStores(pool: PoolLike, options?: MigrateOptions): Promise<void>;   // idempotent DDL for all tables
interface MigrateOptions { lockTimeoutMs?: number; onWarn?: (message: string) => void; backfillParkFences?: boolean; snapshotCompression?: "lz4" | "default"; snapshotCodec?: "zstd" | "none" }
function ensureSnapshotZstd(pool: PoolLike, options?: { lockTimeoutMs?: number; onWarn?: (message: string) => void }): Promise<boolean>;   // provisions snapshots.state_z; migrate(pool) must have completed first; memoized per pool
function postgresPool(config: string | PoolConfig): Pool;   // a real pg.Pool from this package (totalCount / idleCount, which PoolLike omits)

const MAX_EXECUTION_ID_BYTES = 1024;   // UTF-8 bytes; the cross-node NOTIFY payload is capped at 8000. Derived streams (inbox, continueAsNew successor) are slightly longer, so keep ids well below it
class ExecutionIdTooLongError extends RangeError { executionId: string; bytes: number }   // nothing written; start() wraps it, so check err.cause

interface PostgresEventStoreOptions {
  notifyBuckets?: number;              // NOTIFY channels for interest-filtered cross-node delivery; every node must agree. Default 16
  migrationLockTimeoutMs?: number;     // wait for the one-time collation conversion's table lock. Default 2000
  backfillParkFences?: boolean;        // ADR 0118 D1: one-time park-fence backfill at boot. Default true
  snapshotCompression?: "lz4" | "default";
  onWarn?: (message: string) => void;
  deliveryLookupTimeoutMs?: number;    // bound on a cross-node delivery's lookup. Default 30000
  incarnationRecheckMs?: number;       // ADR 0129: min ms between re-probes for a missing events.incarnation. Default 30000
  clock?: () => number;                // default Date.now
}

// The preset for defineConfig({ storage }) — one pool shared by every store, which also builds a co-located leaseStore and an archiveStore
function postgresStorage(options: PostgresStorageOptions): CustomStorage & { leaseStore: LeaseStore }
interface PostgresStorageOptions {
  url: PgConfig;                       // connection string or pool spec
  branching?: boolean;                 // also provision a branch registry for fork/branch
  notifyBuckets?: number;              // as above. Default 16
  migrationLockTimeoutMs?: number;     // as above. Default 2000 ms; the first store to migrate the pool (the event store) wins
  backfillParkFences?: boolean;        // as above. Default true
  snapshotCompression?: "lz4" | "default";   // ADR 0136. Default "default"
  snapshotCodec?: SnapshotCodecName;   // ADR 0141: zstd in snapshots.state_z. Default "none"; needs Node >= 22.15
  onWarn?: (message: string) => void;  // the event store's warnings (a full NOTIFY queue); observability.onWarn does not reach it. Default console.warn
}

Drop-in via defineConfig({ storage: postgresStorage({ url }) }) (imported from @loom/plugin-postgres only by apps that use it, so pg never reaches browser bundles), or the custom storage kind. Single-writer-per-execution (D3) stays the lock-free shard model. See compute/scheduling/storage (loom/docs/loom-2.0/03-compute-scheduling-storage.md).


@loom/plugin-content — the content-addressed blob seam

Large values leave the log by REFERENCE. The log stays the thin, ordered authority; the bytes live in a store addressed by their own hash (ADR 0046).

interface ContentStore {
  readonly sealsContent?: boolean   // SPI 1.5.0 (ADR 0143): true = seals each blob under its owner's subject key (EncryptingContentStore)
  // SPI 1.5.0: every data call takes an OPTIONAL `access?: ContentAccess { executionId, workflowId }` (the owning
  // execution); a plain store ignores it, a sealing store REFUSES a call without it
  ownedBy?(ref: ContentRef, access: ContentAccess): Promise<boolean>   // OPTIONAL, sealing stores: the ownership proof erasure/export ask (ADR 0143 D5)
  put(bytes: Uint8Array, access?: ContentAccess): Promise<ContentRef>   // plain store: address IS sha256(bytes)
  get(ref: ContentRef, access?: ContentAccess): Promise<Uint8Array | null>
  getMany?(refs: readonly ContentRef[], access?: ContentAccess): Promise<(Uint8Array | null)[]>   // OPTIONAL, coalesced
  putStream?(stream: AsyncIterable<Uint8Array>, access?: ContentAccess): Promise<ContentRef>      // OPTIONAL
  delete(ref: ContentRef): Promise<void>
  // SPI 1.3.0 (ADR 0055 D5/D6), OPTIONAL, all three or none — then EVERY put bumps the touch
  // time, monotonically; a touch time of 0 is UNKNOWN (never orphan-eligible)
  deleteIfUntouchedSince?(ref: ContentRef, cutoffMs: number): Promise<boolean>   // one atomic statement
  listPage?(opts: ContentListPageOptions): Promise<ContentListPage>              // keyset on address
  touchClock?(): Promise<number>          // now, on the clock touch times are stamped with
  // SPI 1.4.0 (ADR 0055 D6 amendment 2), OPTIONAL: bump touch times WITHOUT a put (max, batch).
  // Unknown addresses are a no-op, resolved as the missing list. ContentResolvingEventStore calls it
  // for refs a fork copies / an append carries; reclaimOrphans (erasing) REQUIRES it.
  touch?(addresses: readonly string[]): Promise<string[]>
}
interface ContentListPageOptions { cursor?: string; limit?: number; touchedBefore?: number }
interface ContentListPage { entries: ContentPageEntry[]; nextCursor?: string }   // no cursor = done
interface ContentPageEntry { address: string; touchedAt: number }
function contentListPageLimit(limit: number | undefined): number   // RangeError unless a positive integer
function encodeValue(value: unknown): Uint8Array; function decodeValue(bytes: Uint8Array): unknown
function isContentRef(value: unknown): value is ContentRef
// adds every ref in `value` to `into` (first per address wins; a claim-check envelope is a leaf); true iff it found one
function collectContentRefs(value: unknown, into: Map<string, ContentRef>): boolean
class ContentResolvingEventStore implements EventStore {}   // externalize on write, resolve on read;
//   options: { externalize, onTouchError?: (err, info: TouchReport) => void } — SPI 1.4.0 touch around ref-carrying
//   writes: BEFORE (a failure aborts the write), AFTER (best-effort; a failure is reported here, not raised);
//   either touch finding a cited BARE ref missing is reported here as ContentMissingError (data loss already happened)
class ContentTouchingSnapshotStore implements SnapshotStore {}   // (inner, content, { onTouchError? }) touches the
//   citations of every SAVED state: the fork seed of a compacted parent is a citation path ContentResolvingEventStore
//   cannot see. Wrap OUTSIDE an EncryptingSnapshotStore.
const MIN_ORPHAN_ERASE_MARGIN_MS: 60000 // ReclaimOrphansMarginError below this (see reclaimOrphans)
class CachingContentStore implements ContentStore {}        // forwards every optional the inner store has
// GC: a ref is reachable only from the log (and from `roots`), so a sweep needs to know what PINS one
function sweepUnpinnedContent(options: ContentSweepOptions): Promise<ContentSweepReport>
interface ContentSweepOptions {
  eventStores: readonly (Pick<EventStore, "read"> & ExecutionEnumerable)[]   // live AND every archive
  contentStore: Pick<ContentStore, "delete" | "deleteIfUntouchedSince" | "listPage" | "touchClock" | "touch">
  pinnedEventTypes: Iterable<string>; dryRun?: boolean
  olderThanMs?: number                  // EXACTLY ONE of olderThanMs / policy (ADR 0145: policy.content.unpinnedAfterMs; a policy with no content section throws)
  policy?: RetentionPolicy
  now: number                           // LOG time: the TTL is measured back from it
  touchedBefore?: number                // STORE time: guard cutoff (default touchClock() − olderThanMs)
  snapshotStores?: readonly SnapshotLookup[]   // checkpoint + latest snapshot refs are pinned
  roots?: () => AsyncIterable<string>   // D7: addresses held in your own tables, pinned
  archiveStores?: readonly Pick<ArchiveStore, "read" | "listExecutions" | "headers">[]   // an ERASING pass over compacted logs needs these to cover every one (ArchiveStoresRequiredError)
  acknowledgeUnarchivedLogs?: boolean   // opt-in for compacted logs whose history is already gone (reported in unarchivedAcknowledged)
  reclaimOrphans?: { completeScan: true; snapshotsTouchContent?: true }  // D6: erasing REQUIRES the ack (bare `true` throws ReclaimOrphansAckError;
                                        // a dryRun may pass `true`); live stores BEFORE archives.
                                        // erasing also REQUIRES contentStore.touch (SPI 1.4.0) or it throws ReclaimOrphansTouchError (dryRun exempt)
                                        // The guarantee is D5's: touch-before protects a write shorter than the margin
                                        // (store-now − cutoff); touch-after is best-effort. An ERASING pass refuses
                                        // olderThanMs <= 0 or a margin < MIN_ORPHAN_ERASE_MARGIN_MS (60 s) with
                                        // ReclaimOrphansMarginError (dryRun exempt)
                                        // needs listPage + deleteIfUntouchedSince, and
                                        // snapshotStores when a scanned store can truncatePrefix; an erasing pass given snapshotStores
                                        // also REQUIRES snapshotsTouchContent: true (ReclaimOrphansSnapshotAckError)
}
interface SnapshotLookup {   // structurally a SnapshotStore; pass the live one AND each archive's
  loadLatest(executionId: string): Promise<{ sequence: number; state: unknown } | null>
  loadLatestBefore(executionId: string, sequence: number): Promise<{ sequence: number; state: unknown } | null>
}
interface ContentSweepReport {
  executionsScanned: number; refsSeen: number; refsPinned: number; refsLoadBearing: number
  refsAgedByEnd: number; heldByEndClock?: string[]   // ADR 0146: aged from the citing execution's end (held list: dry run only)
  refsOfReopened: number; reclaimedWhileReopened?: string[]   // re-opened executions keep the citation clock (list: dry run only)
  keptByTouch: number; touchCutoff?: number   // eligible blobs the conditional erase refused (0 on a dry run), and the store-clock cutoff
  reclaimed: string[]; orphansReclaimed: string[]
  snapshotStoresTouching: number; snapshotStoresUnbranded: number
  sealedSnapshots: string[]; shreddedSnapshots: string[]; unarchivedAcknowledged: string[]
  raceSafe: boolean                     // D5: false = plain delete, a mid-sweep re-cite can be lost
}
function pinByEventKinds(store: Pick<EventStore, "read">, kinds: Iterable<string>): (executionId: string) => Promise<boolean>
const CONTENT_STORE_SPI_VERSION: string   // "1.5.0"
class InMemoryContentStore implements ContentStore { constructor(options?: { now?: () => number }) }

@loom/plugin-durable-state — carry state across wakes

interface DurableStateStore {
  load(key: string): Promise<DurableStateRecord | null>        // DurableStateRecord = { contractVersion: number; revision: number; payload: unknown }
  save(key: string, value: { contractVersion: number; payload: unknown }, expectedRevision: number): Promise<DurableStateRecord>
  clear(key: string): Promise<void>
  close?(): Promise<void>
}
function durableState<T>(config: DurableStateConfig<T>): DurableStateHandle<T>   // config: { id, version: number, schema, migrations?, store }
// DurableStateHandle<T>: register(runtime: EffectRegistry) registers the load/save/clear effects (toolIds, tasks, effects are also exposed);
//   load(ctx, key, opts?: { stepId? }): Promise<LoadResult<T>>; save(ctx, key, value, expectedRevision: number, opts?): Promise<SaveResult>; clear(ctx, key, opts?): Promise<ClearResult>
class RevisionConflictError extends Error {}   // optimistic concurrency: two wakes cannot interleave (also StateContractMismatchError)
class InMemoryDurableStateStore implements DurableStateStore {}
// durable: SqliteDurableStateStore / PostgresDurableStateStore. ADR 0053; the sentinel preset wires it.

@loom/plugin-sqlite — single-node durability

class SqliteEventStore implements EventStore { constructor(dbPath: string) }          // no fencing: it has no acquireFence (the process is the failure domain); appendIfUnchanged always reports fence 0
class SqliteSnapshotStore implements SnapshotStore { constructor(dbPath: string, options?: SqliteSnapshotStoreOptions) }   // SqliteSnapshotStoreOptions: { codec?: SnapshotCodecName }
class SqliteBranchRegistry implements BranchRegistry { constructor(dbPath: string) }
class SqliteArchiveStore implements ArchiveStore { constructor(dbPath: string) }
class SqliteLeaseStore implements LeaseStore { constructor(dbPath: string) }              // heartbeat leases; the preset opens it on the events.db file, lazily
class SqliteMemoryStore implements MemoryStore {}        // FTS5 external-content index for `search`
class SqliteReadModelStore implements ReadModelStore {}
class SqliteDurableStateStore implements DurableStateStore {}
class SqliteContentStore implements ContentStore { constructor(dbPath: string, options?: SqliteContentStoreOptions) }   // SqliteContentStoreOptions: { now?: () => number }; SPI 1.4.0 (touch); 1.5.0 owner argument is accepted and ignored
class SqliteTimerStore implements TimerStore {}
function sqliteStorage(options?: SqliteStorageOptions): CustomStorage & { leaseStore: LeaseStore }   // the preset for defineConfig({ storage }); files events.db, snapshots.db, archive.db (+ branches.db)
interface SqliteStorageOptions { dir?: string /* default ".loom" */; branching?: boolean; snapshotCodec?: SnapshotCodecName /* default "none" */ }

@loom/plugin-read-model — durable cross-execution indexes

A read model is a CACHE of the log with a watermark, never an authority: drop it and rebuild by re-scanning. Ingestion converges from any mix of live tail, catch-up scans and multiple nodes, because apply is idempotent per row id and watermarks are monotonic per execution.

class ReadModelService {
  constructor(store: ReadModelStore, options?: {
    onError?: (err: unknown, event: EventEnvelope) => void,  // folded models: the event has NO payload/metadata
    liveSource?: ReadModelEventSource,       // fills a live-tail hole (folded); else FoldGapError until catchUp
    sensitive?: SensitiveMap,                // eventType -> payload dot-paths; unioned with the model's own (ADR 0132 D11)
    foldCache?: { maxEntries?: number } | false,   // live write path only; default 1024; false or { maxEntries: 0 } disables
  })
  register(model: ReadModelDefinition): void      // { id, version?, kinds, indexes, extract }
  handleEvent(event: EventEnvelope): void         // wire into defineConfig({ observability.onEvent })
  catchUp(sources: readonly ReadModelEventSource[]): Promise<CatchUpReport>   // watermark-incremental
  query(modelId: string, q?: ReadModelQuery): Promise<ReadModelPage>          // keyset cursor
  purge(modelId: string, where: Record<string, ReadModelScalar>): Promise<number>  // ADR 0093; erasure
  rebuild(modelId: string, sources: readonly ReadModelEventSource[]): Promise<CatchUpReport>
  pins(): Readonly<Record<string, string>>                     // ADR 0095 — declared `version`s
  checkPins(recorded: Readonly<Record<string, string>>): PinReport   // a QUERY: clears nothing
  // Folded-state models (ADR 0132): one durable state per execution, folded with a projection$ reducer.
  registerFolded<S>(def: { projection: ProjectionDefinition<S>; keyOf?(executionId: string): string | undefined;
                           scopeOf?(executionId: string): string | undefined; sensitive?: SensitiveMap }): Promise<{ rebuilt: boolean }>
  getMany<S>(modelId: string, executionIds: readonly string[], opts?: { refreshFrom?: readonly ReadModelEventSource[]; concurrency?: number }): Promise<Map<string, FoldedState<S>>>
  getManyByKey<S>(modelId: string, keys: readonly string[], opts?: FoldedReadOptions): Promise<Map<string, FoldedState<S>[]>>
  refresh(modelId: string, executionIds: readonly string[], sources: readonly ReadModelEventSource[], opts?: { concurrency?: number }): Promise<number>
  purgeFolded(modelId: string, selector: { executionId: string } | { key: string } | { scope: string }): Promise<number>  // tombstone
  drain(): Promise<void>                          // await in-flight live applies
}
const MAX_QUERY_LIMIT = 10_000                    // backends MUST clamp through clampQueryLimit
function assertPurgeFilter(where: Record<string, ReadModelScalar>): void  // empty filter is REFUSED
const READ_MODEL_STORE_SPI_VERSION: string        // pin a third-party row store against this
const MAX_GET_MANY = 1_000                        // keys per getMany / getManyByKey call; more throws RangeError
const DEFAULT_FOLD_CACHE_ENTRIES = 1_024
function sensitiveMapFromCovers(covers: readonly string[]): SensitiveMap  // ["STEP_COMPLETED.output.ssn"] -> { STEP_COMPLETED: ["output.ssn"] }
class SensitiveStateError extends Error {}        // modelId, executionId, eventType, sequence, reason, statePath; never the value
class FoldGapError extends Error {}               // a hole in the live tail with no liveSource; the next catchUp heals it
class FoldVersionMismatchError extends Error {}   // this node's fold version differs from the stored header
class FoldedUnsupportedError extends Error {}     // the store lacks the optional folded methods
type FoldedState<S> = { executionId: string; key: string | null; sequence: number; state: S }
class InMemoryReadModelStore implements ReadModelStore {}
// durable: SqliteReadModelStore (@loom/plugin-sqlite), PostgresReadModelStore (@loom/plugin-postgres)
// SPI 1.2.0 adds five OPTIONAL methods: beginFold?, applyFolded?, getFolded?, getFoldedByKey?, purgeFolded?
// PostgresReadModelStore(pool, { schema?, tables?: { state?, folds? }, manageSchema? }); foldedStateDdl(opts) is the pure DDL for an app migration
function foldedStates(records: readonly FoldedRecord[]): FoldedState<unknown>[]   // the records a read-only reader may show: erased and "no handled event yet" rows are omitted (the getMany rule)
class FoldedStateValueError extends Error { modelId: string; path: string; reason: "nul" | "lone-surrogate" }   // a folded string the durable backends cannot store identically; never carries the value (ADR 0132 D14)
class PurgeUnsupportedError extends Error { modelId: string }   // purge asked of a backend without it; fall back to clear + rebuild deliberately (O(log))

Folded models: version is required and a changed version (or sensitive map) rebuilds: registerFolded swaps the header and drops the model’s state, and catchUp re-folds it. Handlers receive a masked copy of each event (declared and sealed-marked paths become "[redacted]"); a state that holds a cipher envelope or a value lifted from a sensitive path is refused with SensitiveStateError and the watermark does not advance. getMany* and refresh always read the store; the cache serves only the live write path. getManyByKey(…, { refreshFrom }) refreshes only executions that already have a row for the key. purgeFolded tombstones; rebuild un-tombstones. See plugins and ADR 0132.

@loom/plugin-audit-chain — a tamper-evident, third-party verifiable log

Hash-chains every event into its metadata, which the fold ignores — so replay is byte-identical whether or not it is wired, and any later edit, insert, reorder or drop is detectable out of band.

Who can check it (#382). An hmacSecret makes the log tamper-EVIDENT: verification needs the same secret, so anyone able to check it could also re-chain and re-sign it. A signer makes it third-party VERIFIABLE — an auditor holds only verifier and cannot author an entry. The two are mutually exclusive (different promises), keyId keeps history verifiable across rotation, an unknown key is rejected rather than skipped, and signaturesChecked says whether authorship was checked at all so a hash-only pass cannot be over-read.

function hashChainedEventStore(inner: EventStore, options?: {
  hmacSecret?: string;        // tamper-evident to secret-holders
  signer?: ChainSigner;       // { keyId, sign(hash) } — mutually exclusive with hmacSecret
  verifier?: ChainVerifier;   // { verify(hash, sig, keyId) } — the auditor holds ONLY this
  allowCompaction?: true;     // ADR 0138: lets LogLifecycle.compact run over the chain; verify with verifyChain(id, { archived })
}): HashChainedEventStore
function ed25519Signer(privateKey: KeyLike, keyId: string): ChainSigner
function ed25519Verifier(publicKeys: Record<string, KeyLike>): ChainVerifier   // keyId → public key
interface HashChainedEventStore extends EventStore {
  verifyChain(executionId: string, options?: { readonly archived?: readonly EventEnvelope[] }): Promise<ChainVerification>   // { ok, length, signaturesChecked, brokenAt?, reason? }
}
// compose BELOW tenant-scoping and ABOVE the base store, and OUTSIDE ContentResolvingEventStore —
// inside it, the chain only ever sees a ref string and rewriting the blob is silent
// optional SPI: every method forwarded (appendBatch chains per execution, heads advance only after
// the batch is durable) EXCEPT truncatePrefix / truncatePrefixKeeping, REFUSED — a compacted
// log fails verifyChain

Effects — @loom/effect-runtime

All side effects (tool / LLM / human / A2A) run through the EffectRuntime, keyed by toolId. On replay a completed effect is served from the log, never re-executed.

interface EffectContext {
  executionId: string; stepId: string; workflowId: string; attempt: number;
  emit(eventType: string, payload: unknown): void;
  readonly signal?: AbortSignal;      // cancellation
  readonly resources?: ResourceSnapshot;  // (2.0, K3) running totals BEFORE this effect
  readonly priority?: number;             // (2.0, M6) @loom/scheduler Priority when scheduled
  readonly callKey?: string;              // (L30) set only on execution-stamped TRANSIENT calls; id-minting sites append it to (executionId, stepId)
  readonly toolId?: string; readonly taskKind?: string; readonly onDelta?: DeltaSink; readonly idempotencyKey?: string; readonly principal?: PrincipalChain;
}
interface ResourceSnapshot { tokens: number; costUsd: number; ms: number; count: number; exceeded?: boolean; /* plus optional memBytes, wallMs, netBytes, custom */ }
interface Effect<TIn, TOut> { kind: EffectKind; execute(input: TIn, ctx: EffectContext): Promise<TOut>; }

type EffectNext = (input?: unknown) => Promise<unknown>;
interface EffectMiddleware { (ctx: EffectContext, input: unknown, next: EffectNext): Promise<unknown> }   // middleware[0] outermost; also optional `inspectsOutput?: boolean`, `deltaGuard?`

class EffectRuntime {
  constructor(middleware?: readonly EffectMiddleware[], options?: EffectRuntimeOptions)  // (2.0) options
  register(toolId: string, effect: Effect): void
  execute<TIn, TOut>(toolId: string, input: TIn, ctx: EffectContext): Promise<TOut>       // authorize → warm → schedule → middleware → effect
  invokeTransient<TIn, TOut>(toolId: string, input: TIn, opts?: TransientInvokeOptions): Promise<TOut>  // (ADR 0066) non-durable invoke: authorize/meter/middleware still run, but NO events emitted; opts.as (L30) stamps an execution's workflowId/executionId/stepId
  has(toolId: string): boolean
}
interface TransientInvokeOptions { signal?: AbortSignal; principal?: PrincipalChain; label?: string; as?: TransientIdentity; resources?: ResourceSnapshot; onDelta?: DeltaSink; }  // (ADR 0066) — signal (deadline), principal (K2 still enforced), label (trace tag; without `as` it is the synthetic executionId `transient:<label>`, so also an authz input); (L30) as = stamp an execution's identity (an AUTHZ INPUT: any memo/dedup wrapper MUST key on it, and on label — key on every option except signal), resources = running totals for budget admission (the charge is still dropped)
interface TransientIdentity { workflowId: string; executionId: string; stepId?: string /* default toolId */; callKey?: string /* -> EffectContext.callKey */ }
interface DecideScope { invokeTransient<TIn, TOut>(toolId: string, input: TIn, opts?: { readonly signal?: AbortSignal; readonly onDelta?: DeltaSink }): Promise<TOut> }  // (L30) ctx.decide's decider argument; closed once the decider settles

The protection ring, accounting context & scheduling (2.0, K2/K3/M6).

interface EffectRuntimeOptions {
  authorizer?: CapabilityAuthorizer;   // K2 — deny ungranted effects (least-privilege)
  scheduler?: Scheduler;               // M6 — route effects through it (bounded concurrency + priority)
  warmer?: Warmer;                     // M6 — advisory pre-warm, off-log (latency only)
  onWarn?: (message: string) => void;
}
interface CapabilityAuthorizer { authorize(req: CapabilityRequest): CapabilityDecision; }
interface CapabilityRequest  { toolId: string; resource?: string; executionId: string; workflowId: string; stepId: string; principal?: PrincipalChain; }
interface CapabilityDecision { allowed: boolean; reason?: string; }
class CapabilityDeniedError extends Error { readonly toolId: string; readonly reason: string; }
class GrantTable implements CapabilityAuthorizer {   // least-privilege: principal → granted effect ids ("*" = all)
  constructor(grants: Record<string, readonly string[]>, options?: { principal?: (req: CapabilityRequest) => string })
}
interface Warmer { warm(toolId: string): void; }

A denied effect never runs: it emits EFFECT_DENIED (folds into variables._denials) and throws CapabilityDeniedError, failing the step. All three options are opt-in — omit them and execute is the unchanged zero-overhead path.

Identity, tenancy & delegated authority (2.0, K12). A CapabilityRequest can carry a PrincipalChain (tenant / actor / on-behalf-of / a verifiable delegation). DelegationAuthorizer honors a signed, attenuated delegation only after verifying its HMAC and intersecting its grants with the callee’s own authority (base, required) — a delegate can never invoke an effect the delegator lacked, nor one the callee lacks (no ambient escalation). Attenuation only narrows. The HMAC secret is symmetric, so the signature stops a third party forging a grant set in transit but cannot stop the verifier minting its own; the intersection is what bounds the callee. Cross-organisation delegation is out of scope — see ADR 0024.

interface PrincipalChain { tenant?: string; actor?: string; onBehalfOf?: string; delegation?: DelegatedGrant; [k: string]: unknown; }
interface DelegatedGrant { grants: string[]; issuedTo: string; exp?: number; sig: string; } // HMAC-SHA256 over issuedTo + sorted grants (+ exp)
function signDelegation(secret: string, issuedTo: string, grants: string[], exp?: number): DelegatedGrant;
function verifyDelegation(secret: string, token: DelegatedGrant): boolean;       // constant-time
function attenuateDelegation(secret: string, token: DelegatedGrant, issuedTo: string, subset: string[], exp?: number): DelegatedGrant; // narrow only; inherits parent exp
class DelegationAuthorizer implements CapabilityAuthorizer {
  constructor(options: { secret: string; base: CapabilityAuthorizer; now?: () => number })
  // `base` is REQUIRED and load-bearing: the secret is symmetric, so the verifier can also
  // mint. Only the intersection with the callee's own authority bounds it (ADR 0024).
}

Tenant isolation is enforced by construction at the store — see TenantScopedEventStore / TenantScopedSnapshotStore under Replay & storage.

Effect implementations (each implements Effect):

new ToolEffect((input, ctx) => Promise<TOut>, options?: { idempotent?, errors?, mode? })   // kind "tool" — any async work
new LlmEffect((input, ctx) => Promise<TOut>, options?: { warm?, reasoning? })              // kind "llm"  — a model call
new A2AEffect((input, ctx) => Promise<TOut>)     // kind "a2a"  — delegate to a remote agent (defined in @loom/plugin-a2a only; the effect-runtime copy was removed, ADR 0115)
new HumanEffect({ on: string; timeout?: number; correlationKey?: (input) => string })  // kind "human" — throws SuspendError
new LazyEffect(spec: HandlerSpec)                // kind "tool" — resolves an inline/lazy handler on first execute

Kernel hardening & security

The kernel-hardening rings (K1–K12) and kernel services (T1–T2) turn Loom into something that earns the word “kernel.” Every guardrail ring is opt-in and inert until wired (P10); each new verdict folds like any other event (P9). Most rings surface through packages already documented above — this section is the index, plus the three packages that exist only for hardening. See the architecture narrative in §5.7 and the ring-by-ring detail in 04 — Kernel Hardening (loom/docs/loom-2.0/04-kernel-hardening.md).

RingConcernWhere in this referenceDurable fact
K1Versioned syscall ABIKERNEL_ABI under ExecutionContext—
K2Capability authorityCapabilityAuthorizer/GrantTable under EffectsEFFECT_DENIED
K3Resource metering & quotasmeteringMiddleware under @loom/middlewareRESOURCE_METERED / QUOTA_EXCEEDED
K4Fault supervisionSupervisingDispatcher under @loom/dispatcher—
K5Event-schema governanceEVENT_SCHEMA_VERSION (event-schema ref (loom/docs/loom-2.0/10-event-schema-reference.md))—
K6Behavioral safetysafetyMiddleware under @loom/middlewareSAFETY_BLOCKED
K7Data-plane isolation & egress@loom/isolate (below)EGRESS_DENIED
K8Cancellation & deadlinesdeadlineMs/signal under ExecutionContextEXECUTION_CANCELLED
K9Delivery contract & fencingidempotent (tool / effect) + WorkerLease.fenceEFFECT_INDOUBT
K10Log lifecycle & encryption@loom/plugin-crypto (below) + redactionMiddlewarePAYLOAD_SHREDDED
K11Flow-code versioningresumeAs/flowContentHash under WorkflowRuntimeEXECUTION_MIGRATED
K12Identity, tenancy & delegationDelegationAuthorizer + TenantScoped* storesAPPROVAL_GRANTED
T1Durable timers & schedulesctx.sleep/schedule$ + defineConfig({ timers })TIMER_FIRED
T2Wire-level kernel ABI@loom/wire-abi (below)—

@loom/isolate — data-plane isolation & brokered egress (K7)

Runs your own tool code in a separate child process (hardening, not OS confinement; model-written code goes through @loom/sandbox) — its own authority domain. The tool’s code is a module the child loads, never a closure over parent state; the child’s env is scrubbed to exactly the credentials declared for that one tool; all network I/O is brokered kernel-side against a static allowlist (resolve-and-pin, so a DNS rebind can’t redirect it), and ctx.signal/timeoutMs SIGKILLs the child.

class IsolatedToolEffect implements Effect {          // kind "tool" — drop-in for ToolEffect
  constructor(spec: IsolatedToolSpec)
  warm(): void            // advisory pre-warm: spawn the child before first execute
  close(): void           // SIGKILL the warm child (tests / shutdown)
}
interface IsolatedToolSpec {
  id: string;             // effect id (used in durable facts/errors)
  modulePath: string;     // absolute, CJS-requireable module the CHILD loads
  exportName?: string;    // named export implementing (input, ctx) => Promise<out>; default "default"
  egress?: string[];      // K7.3 static allowlist, explicit "host:port"; omit/empty = no network at all
  env?: Record<string, string>;  // K7.4 credentials for THIS tool only; child env is otherwise scrubbed
  timeoutMs?: number;     // exceed ⇒ non-cooperative SIGKILL
  idempotent?: boolean;   // K9.3 delivery semantic
  warm?: boolean;         // pre-warm the child at construction
  maxConcurrentFetches?: number; coldStartMs?: number; fetchTimeoutMs?: number;   // optional bounds
}
// the marshaled context an isolated tool receives (a host-function subset of EffectContext):
interface IsolatedToolContext {
  executionId: string; stepId: string; workflowId: string; attempt: number; idempotencyKey?: string;
  emit(eventType: string, payload: unknown): void;
  signal: AbortSignal;
  fetch(url: string, init?: IsolatedFetchInit): Promise<IsolatedFetchResponse>;  // brokered — allowlist enforced parent-side
}
// the kernel-side egress broker (denies before any connection, emits EGRESS_DENIED):
function brokeredFetch(toolId: string, allowlist: ReadonlySet<string>, url: string, init?: IsolatedFetchInit, signal?: AbortSignal, hooks?: BrokeredFetchHooks): Promise<BrokeredResponse>;
class EgressDeniedError extends Error { readonly toolId: string; readonly target: string; readonly reason: string; }

@loom/plugin-crypto — encryption-at-rest & crypto-shredding (K10)

Wraps any EventStore/SnapshotStore to encrypt marked payload fields (dot-paths per event type) under a per-subject key on append and decrypt on read (AES-256-GCM; unmarked events stored verbatim — zero overhead). Erasure is by key destruction: shredSubject destroys the key (irreversible for everything encrypted under it, in events and snapshots) and appends a durable PAYLOAD_SHREDDED tombstone. A later decrypting read of a shredded execution throws PayloadShreddedError — replay fails loudly, never silently diverges. Optional store capabilities (fencing, readRange, copyPrefix) are forwarded, so it composes with K9.4 and fork().

class EncryptingEventStore    implements EventStore    { constructor(inner: EventStore,    keys: KeyStore, options: EncryptingEventStoreOptions) }
class EncryptingContentStore implements ContentStore {   // ADR 0143, ContentStore SPI 1.5.0
  constructor(inner: ContentStore, keys: KeyStore, options?: { subjectOf?: (o: { executionId: string; workflowId: string }) => string })
  readonly sealsContent: true; readonly keys: KeyStore; readonly subjectOf   // defineConfig compares both with security.crypto.events by identity
  // put/get/getMany REQUIRE the `ContentAccess` owner (ContentAccessRequiredError); address = inner hash of the ciphertext (subject-scoped);
  // destroyed key => PayloadShreddedError, tampered/swapped blob => ContentUndecryptableError; no putStream
  ownedBy(ref, access): Promise<boolean>   // ownership PROOF (decrypt + discard): erasure deletes / export opens only blobs the subject sealed; a merely CITED blob is foreign
}
class EncryptingSnapshotStore implements SnapshotStore { constructor(inner: SnapshotStore, keys: KeyStore, options: EncryptingSnapshotStoreOptions) }
interface EncryptingEventStoreOptions {
  sensitive: Record<string, readonly string[]>;   // event type → encrypted payload dot-paths
  subjectOf?: (e: { executionId: string; workflowId: string }) => string;  // erasure subject (default: executionId)
  ivSource?: IvSource;   // simulation only: a BRANDED value from @loom/plugin-crypto/testing (a plain function is refused); allowed only under NODE_ENV=test / VITEST
  senderSubjectOf?: SenderSubjectOf | false;   // ADR 0139 D12 (see below)
  onShredded?: "throw" | "tolerate";
}
interface EncryptingSnapshotStoreOptions {
  sealState?: true;                    // ADR 0130 D8: seal the WHOLE folded state as one envelope (covers dynamic _waitAnswers keys); exposes sealsWholeState: true
  legacyPlaintext?: "load" | "refuse";  // sealState only; default "load"; "refuse" throws PlaintextSnapshotRefusedError on an unsealed state
  legacySensitive?: readonly string[]; // sealState only; legacy opening restricted to these paths
  sensitive?: readonly string[];       // static dot-paths into the folded state; mutually exclusive with sealState
  subjectOf?: (s: { executionId: string; workflowId: string }) => string;
  codec?: SnapshotCodecName; lengthLeak?: "accept"; ivSource?: IvSource; events?: EventStore;   // codec/lengthLeak: ADR 0141; events: see below
}
// ADR 0130 residuals D7: every envelope with a sealed field is stamped metadata.seal = { v: 1, cipher, covers }
// (covers: exact, sorted "EVENT_TYPE.path" entries sealed in THAT envelope), and store.sealingCapability()
// reports what this node seals and opens (live; registerSensitive is reflected).
// store.readAtRest?() (EventStore SPI 2.5.0, ADR 0130 D9): a read-only view (read/readFrom/readRange?/head?) of the
// envelopes exactly as stored, never decrypted; EncryptingEventStore implements it, decorators forward it.
// EncryptingSnapshotStoreOptions.events?: EventStore — the RAW event store, so a missing key is judged by the
// log's PAYLOAD_SHREDDED tombstone (PayloadShreddedError) vs none (KeyUnavailableError); without it the old PayloadShreddedError stands.
// EventStore SPI 2.6.0 (ADR 0139 D8): deleteThrough?(executionId, through) - ONE atomic delete of every event with
// sequence <= through, sequence 0 included; a concurrent append above `through` survives. Callers that need it refuse without it.
// EncryptingEventStoreOptions.senderSubjectOf?: (sender) => string | undefined (ADR 0139 D12) - seals an inbox envelope's
// sender under the SENDER's subject (an OPAQUE handle); a read after the sender's shred yields { erased: true }.
// EncryptingArchiveStoreOptions.senderSubjectOf? - the same mapping for the archive; encryptingEventStore.sealArchive(archive).
// import { seededIvSource, type IvSource } from "@loom/plugin-crypto/testing";   // deterministic IVs for DST
class KeyUnavailableError extends Error { code: "KEY_UNAVAILABLE"; subjectId: string; streamId?: string }  // @loom/event-runtime
// key absent and NO tombstone: this node's KeyStore lacks it. The kernel turns it into a denial-shaped
// SealedStreamUnreadableError (an ExecutionDeniedError with UNKNOWN_OWNER_REASON; isSealedUnreadable(e));
// the real cause: sealedDetailOf(err) from "@loom/workflow-runtime/operator" (operators only).
// getOrCreate MUST be an atomic create-if-absent across nodes (ADR 0139 D22; runKeyStoreConformance has a concurrent case a non-atomic store fails)
interface KeyStore { getOrCreate(subjectId: string): Promise<Buffer>; get(subjectId: string): Promise<Buffer | null>; destroy(subjectId: string): Promise<void>;
                    wasDestroyed?(subjectId: string): Promise<boolean>; }  // ADR 0130 D7 r5: true only if THIS store destroyed the key
class InMemoryKeyStore implements KeyStore { … }   // reference (tests / ephemeral)
function shredSubject(keys: KeyStore, rawEventStore: EventStore, executionId: string,
                      options?: { subjectId?: string; reason?: string; executionIds?: string[];
                                  tombstoneSequenceOf?: (executionId: string) => Promise<number>;
                                  // raw OR encrypting (tenant-scoped or not): the tombstone follows the FOLDED
                                  // tip, read via loadLatestSealed (SnapshotStore SPI 1.1.0, never decrypts).
                                  // Required (or tombstoneSequenceOf) when rawEventStore can truncatePrefix.
                                  snapshotStore?: Pick<SnapshotStore, "loadLatest" | "loadLatestSealed">;
                                  // operator override: assert the key was destroyed in the authoritative store
                                  // (a retry otherwise needs keys.wasDestroyed(subject) === true)
                                  force?: "key-destroyed-elsewhere" }): Promise<void>;
// EraseCoordinator({ destroyed }) / exportSubject({ destroyed }) / compliance.erase.destroyed: DestructionProof
// EncryptingSnapshotStore.loadLatestSealed(id) — latest snapshot at rest, sensitive fields left as ciphertext
class PayloadShreddedError extends Error { … }     // thrown on any read of a shredded subject
// Recognised by the realm-safe BRAND Symbol.for("loom.payloadShredded"), never by the name alone (ADR 0039, 2026-10-17):
// import { isPayloadShredded, brandPayloadShredded, PAYLOAD_SHREDDED_BRAND } from "@loom/event-runtime";
// isPayloadShredded(err): boolean   // true only with the brand; a store that cannot extend the class calls brandPayloadShredded(err)
class ShredIncompleteError extends Error { subjectId: string; failures: { executionId: string; error: unknown }[] }
// key destroyed, some tombstones not written: re-run shredSubject with the same arguments.
// Retry-safe: sequences resolved before the key is destroyed; a re-run writes only the missing
// tombstones (an execution counts as done when its log tail above the latest snapshot carries the
// subject's PAYLOAD_SHREDDED, or that snapshot's state records it — compaction folds it there).

Pair with redactionMiddleware/secretRef (under @loom/middleware) to keep secrets out of the log in the first place; @loom/plugin-crypto protects what is persisted.

@loom/classify — what a value IS, on two axes

Confidentiality is a property of a FIELD and static; integrity is a property of a VALUE and acquired at runtime from a specific origin (ADR 0085). Both are declarations the runtime enforces elsewhere — masking, encryption, externalization, and the prompt boundary in @loom/llm.

// confidentiality — marks on a schema, read by the kernel's egress/crypto paths
const sensitive: <T extends z.ZodTypeAny>(schema: T) => T; const secret: <T extends z.ZodTypeAny>(schema: T) => T; const internal: <T extends z.ZodTypeAny>(schema: T) => T
function classify<T extends z.ZodTypeAny>(schema: T, dataClass: DataClass): T   // marks the schema (the sugar above calls it); returns the same schema
function collectClassifiedPaths(schema: z.ZodTypeAny, prefix?: string): ClassifiedPath[]   // { path, dataClass } per marked field
function maskPaths(root: unknown, paths: readonly string[], mask?: unknown): unknown   // a copy with `mask` (default REDACTED) at each path
function expandPaths(root: unknown, paths: readonly string[]): string[]   // `[]` crossings (ADR 0076)

// integrity — a wrapper around a value, not a mark on a field
function tainted<T>(value: T, origin: string): TaintedValue<T>
function isTainted(value: unknown): value is TaintedValue; function taintOf(value: unknown): Taint | undefined
function unwrapTainted<T>(value: TaintedValue<T> | T): T
function scanTaint(value: unknown, options?: TaintScanOptions): TaintScan   // @loom/classify (ADR 0085 amendment): finds a wrapper NESTED anywhere in a value (iterative, cycle-safe, depth 64 / 100,000 nodes, fails CLOSED past the bound with origin "unknown:taint-scan-bound-exceeded")
function nestedTaintOrigin(value: unknown, options?: TaintScanOptions): string | undefined   // the joined origin label (capped at 8 origins, then "+N more"); agentLoop$ fences the WHOLE tool_result when it is set
// producers: plugin-mcp taints a whole tool result; plugin-a2a taints `content` only

@loom/compliance — subject export & erasure (T3)

class SubjectIndex {                              // subjectId → executions; fed live (handleEvent), rebuildable
  constructor(subjectOf: SubjectResolver, options?: { tenantOf?: TenantResolver })
  static fromEvents(events: readonly EventEnvelope[], subjectOf: SubjectResolver, options?: SubjectIndexOptions): SubjectIndex
  handleEvent(event: EventEnvelope): void
  executionsOf(subjectId: string, tenant?: string): string[]
}
function exportSubject(subjectId: string, options: ExportSubjectOptions): Promise<SubjectExport>  // Art. 15/20; options.index (SubjectIndex) and options.eventStore are REQUIRED
class EraseCoordinator {                          // Art. 17 — shred now, or park until terminal
  constructor(options: EraseCoordinatorOptions)   // { index, eventStore, shred, archives?, content?, … }
  eraseSubject(subjectId: string, tenant?: string, options?: EraseSubjectOptions): Promise<EraseSubjectResult>
  handleEvent(event: EventEnvelope): Promise<void>   // wire into onEvent: executes a parked erasure at the terminal event
}
const ERASURE_STREAM: string                      // the reserved control stream erasure facts live on
// reached through app.exportSubject / app.eraseSubject when `compliance` is configured

@loom/wire-abi — wire-level kernel ABI (T2)

A language-neutral JSON-RPC projection of the K1 syscalls: a non-TS worker can drive a Loom execution over the wire, and the same fold-conformance vectors pin its behavior byte-for-byte against the in-process kernel.

const WIRE_ABI_VERSION: string;                      // "1.0.0" — the wire protocol's own version; independent of KERNEL_ABI_VERSION, tracks its major line
const WIRE_ABI: WireAbi;                             // { wireVersion, kernelAbiVersion, syscalls, carrier }
const WIRE_METHODS: readonly WireMethod[];           // JSON-RPC projection of the syscalls
const CARRIER_METHODS: readonly CarrierMethod[];     // execution.start / execution.resume / execution.getState / execution.cancel
function deriveWireMethods(syscalls?: readonly KernelSyscall[]): WireMethod[];   // default KERNEL_SYSCALLS
function handleRpc(runtime: WorkflowRuntime, request: JsonRpcRequest, options?: CarrierOptions): Promise<JsonRpcResponse>;   // server side
const FOLD_VECTORS: readonly FoldVector[];           // shared conformance corpus
function runFoldVectors(vectors?: readonly FoldVector[]): Array<{ name: string; passed: boolean; detail?: string }>;   // runs the reference fold; defaults to FOLD_VECTORS

Projection & UI

@loom/projection-runtime

Read models folded from events, decoupled from durability.

interface Projection<TState> {
  id: string;
  version?: string;                              // ADR 0095: declared semantics of the fold, pinned by checkVersionPins
  initialState: TState;
  handlers?: { [eventType: string]: (state: TState, event: EventEnvelope) => TState };
  lazy?: { eventTypes: string[]; load: () => Promise<ProjectionFoldMap<TState>> };   // fold code import()-ed on the first matching event; exactly one of handlers / lazy
}
interface ProjectionRuntime {
  register<TState>(projection: Projection<TState>): void;
  getState<TState>(projectionId: string, executionId: string): TState;
  subscribe<TState>(projectionId: string, executionId: string, handler: (state: TState) => void): () => void;
  // (ADR 0068) fine-grained slice subscription: memoized, change-only per slice. Seeds the last
  // slice from current state at subscribe time (does NOT fire on subscribe); participates in
  // coalesceNotifications. Read the initial value via getState + selector.
  subscribeSelector<TState, TSlice>(
    projectionId: string, executionId: string,
    selector: (state: TState) => TSlice,
    handler: (slice: TSlice) => void,
    isEqual?: (a: TSlice, b: TSlice) => boolean
  ): () => void;
  handleEvent(event: EventEnvelope): void;
  seedState(projectionId: string, executionId: string, state: unknown): void;
  pins?(): Readonly<Record<string, string>>;   // optional: declared version per registered projection (ADR 0095)
}
class InMemoryProjectionRuntime {
  constructor(config?: { coalesceNotifications?: boolean; maxExecutions?: number })   // coalesce: flush once per microtask with latest state; maxExecutions: LRU bound on retained executions, default 10_000
}

@loom/live-query — one execution, pushed on every event

Read models answer questions ACROSS executions; a live query is the other axis — the state of ONE execution, re-folded and pushed each time an event lands. No syscall and no event type: it composes loadState with EventStore.subscribe (P10).

function liveQuery(deps: {
  eventStore: EventStore;
  reader: StateReader;                       // typically the WorkflowRuntime
  onError?: (err: unknown) => void;          // background re-folds have no caller to catch them
  pollMs?: number;                           // re-read the log this often in case a wake-up was lost; stops at a terminal state; 0 = off (a lost wake-up then waits for the next event). Default 5000
}): LiveQuery

interface LiveQuery {
  snapshot(executionId: string): Promise<ExecutionState | null>
  // pushes CURRENT state immediately, then after every subsequent event; coalesced latest-wins,
  // so a burst costs O(1) extra folds and a terminal state is delivered before teardown
  subscribe(executionId: string, onState: (state: ExecutionState) => void): Promise<() => void>
}

@loom/agent-surface — native surface-patch protocol

Loom’s native, event-sourced UI mechanism: flows drive the UI with typed, durable patches that fold into named surfaces. This is **not** the external Google A2UI wire protocol — for interop with A2UI-compliant clients see @loom/plugin-a2ui below, which projects these surfaces onto the A2UI wire format.

const SURFACE_PATCH = "SURFACE_PATCH";
const DEFAULT_SURFACE = "ui";                    // the surface `surface(ctx)` targets

type SurfacePatch =
  | { op: "set";       path: string; value: unknown }
  | { op: "merge";     path: string; value: Record<string, unknown> }
  | { op: "append";    path: string; value: unknown }
  | { op: "remove";    path: string }
  | { op: "increment"; path: string; by?: number };

interface SurfaceEmitter {
  set(path: string, value: unknown): void;
  merge(path: string, value: Record<string, unknown>): void;
  append(path: string, value: unknown): void;
  remove(path: string): void;
  increment(path: string, by?: number): void;
  patch(patches: SurfacePatch[]): void;
  as(by: PatchProvenance): SurfaceEmitter;       // PatchProvenance = { by: string; note?: string }
}
function createSurfaceEmitter(emit: (eventType: string, payload: unknown) => void, surface?: string, author?: PatchProvenance): SurfaceEmitter
function agentSurfaceProjection(surfaceId?: string, options?: { redact?: readonly string[] }): Projection<SurfaceState>   // register on a ProjectionRuntime
function applySurfacePatch(state: SurfaceState, patch: SurfacePatch): SurfaceState        // pure, total
function applySurfacePatches(state: SurfaceState, patches: readonly SurfacePatch[]): SurfaceState
// getSurfaceValue(state, parts: string[]): unknown ;  class AgentSurfaceRuntime { handleEvent/getSurface/seed/subscribe }

@loom/plugin-a2ui — A2UI (Agent-to-UI) adapter

Projects a Loom agent surface onto the real Google A2UI wire protocol v1.0, so any A2UI-compliant client can render a Loom-driven UI. A **1P plugin**, kernel untouched (ADR 0069, ADR 0070). Covers **egress**, the inbound action leg, and the **callFunction bidirectional RPC** (the agent invokes a named client function and receives a functionResponse). Only continuous two-way binding sync remains out of scope.

The bridge folds SURFACE_PATCH events locally and translates Loom’s delta ops into A2UI’s absolute updateDataModel value-replacement (A2UI has no patch framework); remove → contents: null. K10-R6 egress redaction is applied on the way out.

callFunction is reconciled with Loom’s append-only replay grain **not** as a blocking sync RPC in the log, but by the client’s result re-entering as an ordinary recorded fact — a resume event (grain A) or a STEP_COMPLETED output (grain B). No client-side effect runtime is needed; determinism rests on **result-as-recorded-fact + a deterministic callId~**. No new event types; ~EVENT_SCHEMA/KERNEL_ABI unchanged (ADR 0070).

The catalogue named by catalogId is an enforced claim, not a label (ADR 0088 D5, amended): the constructor and recompose both throw for a component tree the supplied catalog does not cover (structural checks below), and callFunction throws for a name the catalogue does not declare under functions — a catalogue declaring none refuses every call rather than allowing one unverifiably. Naming catalogId without supplying catalog is refused outright (unless it is the default, which defaultCatalog backs) — the bridge would otherwise stamp an id it cannot check a single component against.

const A2UI_VERSION = "v1.0";

// One session per (surface, execution): feed it the event stream, get A2UI wire messages.
class A2UISurfaceBridge {
  constructor(opts: {
    executionId: string;                 // which execution's surface
    components: ComponentInstance[];      // the view (component tree); one node must be id:"root"
    surface?: string;                     // Loom surface id (default "ui")
    a2uiSurfaceId?: string;               // emitted wire id (default `${surface}:${executionId}`)
    catalog?: A2UICatalog;                // default `defaultCatalog`; makes catalogId a checked claim
    catalogId?: string;                   // default DEFAULT_CATALOG_ID; REFUSED if named without `catalog`
    redact?: readonly string[];           // K10-R6 egress dot-paths
    surfaceProperties?: Record<string, unknown>;
  });
  get surfaceId(): string;
  snapshot(): A2UIServerMessage[];             // createSurface + current data (late-joining client)
  handleEvent(event: EventEnvelope): A2UIServerMessage[];  // 0+ createSurface/updateDataModel messages
  // Structural update — a new/edited subtree, as opposed to the data updates
  // handleEvent produces. Validated (throws, like the constructor) against the
  // CLIENT's merged view (every batch sent so far, keyed by id — a client MERGES
  // updateComponents by id, it does not replace), not against this batch alone,
  // so a batch may correctly reference a sibling delivered in any earlier batch.
  // requireRoot is false: an updateComponents batch may legitimately be a
  // subtree. Before the session opens, emits nothing — the tree becomes what the
  // pending createSurface will carry.
  recompose(components: ComponentInstance[]): A2UIServerMessage[];
  // Which ids a proposed recompose would DROP (ADR 0094's preservesNodes,
  // shared with blueprint identity) — a pure query, run BEFORE committing.
  identityDiff(next: readonly ComponentInstance[]): IdentityReport;
  close(): A2UIServerMessage[];                // deleteSurface
  ingestAction(msg: A2UIClientMessage): ActionPayload | null;  // inbound: normalize a client action
  // callFunction (bidirectional RPC). callId MUST be deterministic (never random)
  // AND call-site-unique — grain A has no step id, so mint it per call site, e.g.
  // `${executionId}:getLocale#1` (mirrors step identity, ADR 0061). Grain B uses
  // ctx.idempotencyKey (= executionId:stepId), unique per ctx.execute, for free.
  callFunction(name: string, args: unknown, opts: { callId: string; callableFrom?: string }): A2UIServerMessage;
  ingestFunctionResponse(msg: A2UIClientMessage, expectedCallId?: string): FunctionResponsePayload | null;  // inbound: normalize a reply; pass expectedCallId to reject an unsolicited callId
}

// Catalog (mandatory in A2UI — createSurface names a catalogId)
const DEFAULT_CATALOG_ID = "loom.core.v1";
const defaultCatalog: A2UICatalog;           // Text/Column/Row/List/Badge/Button
function defineCatalog(catalog: A2UICatalog): A2UICatalog;   // bring your own
function defineFunction(fn: CatalogFunctionSchema): CatalogFunctionSchema; // a client-callable function

// Grain B — callFunction as a delegated effect. Registers an `a2ui.callFunction`
// effect (idempotent:false → a crash mid-call parks at EFFECT_INDOUBT for an
// operator verdict, not a blind re-fire). Transport injected → plugin stays
// transport-agnostic. Call via `ctx.execute({ id, toolId: A2UI_CALL_FUNCTION_TOOL_ID }, input)`.
const A2UI_CALL_FUNCTION_TOOL_ID = "a2ui.callFunction";
function registerA2UICallFunction(effectRuntime: EffectRuntime, transport: A2UICallFunctionTransport, opts?: { toolId?: string; requestTimeoutMs?: number }): string;

// Component-tree builders (bindings are JSON Pointers into the surface data model)
function bind(pointer: string): { path: string };
function text(id, value): ComponentInstance;
function badge(id, label, tone?): ComponentInstance;
function column(id, children: string[]): ComponentInstance;
function row(id, children: string[]): ComponentInstance;
function list(id, path, itemComponentId): ComponentInstance;   // template iteration
function button(id, label, action: string): ComponentInstance; // raises an inbound action
function jsonDebugView(): ComponentInstance[];                  // render the whole model as JSON

function dotPathToPointer(dotPath: string): string;   // "a.b" → "/a/b" (RFC 6901)

Structural conformance (ADR 0088 D5, amended). Never throws; every finding from one pass. Exact and structural only — checks children REFERENCES, not component prop VALUES (still JSON-Schema $ref documents, unchecked).

function validateComponents(components: readonly ComponentInstance[], catalog: A2UICatalog, options: {
  requireRoot: boolean;   // true for a createSurface tree; false for an updateComponents batch
  // ids already known outside `components` (e.g. what a client already holds) that
  // `children` references may resolve against, in addition to `components` itself —
  // what `A2UISurfaceBridge.recompose` passes as its merged clientView (see below)
  knownComponents?: ReadonlyMap<string, ComponentInstance>;
}): A2UIValidation
// findings: unknown-component | undeclared-prop | duplicate-component-id | missing-root |
//           unchecked-props (warn) | dangling-child | dangling-template | child-cycle |
//           unreachable-component (warn; only when requireRoot AND a root exists)
function validateFunctionCall(name: string, catalog: A2UICatalog): A2UIValidation
// findings: unknown-function — a catalogue declaring no `functions` at all refuses every call
function validateCatalog(catalog: A2UICatalog): A2UIValidation
// findings: catalog-entry-key-mismatch | catalog-function-key-mismatch — a components/functions
// key must equal its own component.const / name, or routing and rendering disagree
function describeFindings(report: A2UIValidation): string   // one-line message for a thrown error

Usage — bridge a surface to a connected client (e.g. an SSE endpoint):

import { A2UISurfaceBridge, column, text, badge, bind } from "@loom/plugin-a2ui";

const view = [column("root", ["status", "score"]), badge("status", bind("/status")), text("score", bind("/stats/score"))];
const bridge = new A2UISurfaceBridge({ executionId, components: view });

send(bridge.snapshot());                        // createSurface (view + current data)
eventStore.subscribe(executionId, (e) => {
  for (const msg of bridge.handleEvent(e)) send(msg);  // updateDataModel per patch
});
// inbound: a client Button action → resume the flow
onClientMessage((m) => {
  const action = bridge.ingestAction(m);
  if (action?.action === "approve") runtime.resume(executionId, { eventType: "TaskApproved", payload: { a2ui: action } });
});

callFunction — the two grains (see ADR 0070; a runnable demo is examples/a2ui-callfunction):

// Grain A — read a client value via a suspend/resume gate (no live socket).
// In the flow: announce the pending call, then suspend on the result.
const callId = `${ctx.executionId}:getLocale#1`;          // deterministic + call-site-unique
surface(ctx).set("pendingCall", { callId, name: "getLocale" });
const reply = (await ctx.suspend({ on: "A2UIFunctionResult", correlationKey: callId })) as { result?: unknown };
// In the driver: emit the request, get the client's answer, resume the gate.
send(bridge.callFunction("getLocale", {}, { callId }));   // → client
const fr = bridge.ingestFunctionResponse(clientMsg);       // client → normalize for this surface
runtime.resume(executionId, { eventType: "A2UIFunctionResult", payload: fr });

// Grain B — side-effecting call as a delegated effect (needs a duplex transport).
registerA2UICallFunction(app.effectRuntime, transport);    // at composition time (registerEffects)
// In the flow: a synchronous-looking call; result folded as STEP_COMPLETED.
const out = await ctx.execute({ _tag: "TaskDefinition", kind: "tool", id: "confirm-submit",
  toolId: A2UI_CALL_FUNCTION_TOOL_ID }, { surfaceId: `ui:${ctx.executionId}`, name: "confirmSubmit", args });

A side-effecting client function must **dedup on callId** (effects are at-least-once, K9.1).

The a2ui.callFunction effect is declared mode: "journaled" (ADR 0073): its recovery story is the journal — a crash mid-call parks at EFFECT_INDOUBT and the callId is ctx.idempotencyKey, neither of which exists transiently. Grain A is unaffected (it is ctx.suspend/resume).

See apps/playground (the “A2UI” tab — the ƒ callFunction button) for a live wiring, and a2ui-callfunction in the sibling loom-examples repo for both grains.

@loom/plugin-mcp-app — MCP Apps (SEP-1865) egress

Projects a Loom agent surface onto the MCP Apps extension so any MCP host renders a Loom-driven UI (ADR 0072). Egress only — the host role is out of scope (D2).

MCP Apps ships code, which is the property A2UI deliberately lacks. That is neutralised by never generating markup per flow: one static template renders a declarative payload — the existing A2UI catalog component tree — delivered as structuredContent. So MCP Apps is a second transport binding for a surface protocol Loom already ships, not a second UI protocol, and the rendered UI is a pure function of (pinned template hash, logged structuredContent).

const SURFACE_HOST_URI  = "ui://loom/surface-host";
const SURFACE_HOST_MIME_TYPE = "text/html;profile=mcp-app";
const SURFACE_HOST_TEMPLATE: string;          // the one static, dependency-free template

function surfaceHostPin(template?: string): string          // `fnv1a64:<hex16>`
function verifySurfaceHostPin(expected: string, template?: string): string  // throws TemplateDriftError
function surfaceHostResource(opts?: { pin?; uri?; connectDomains?; resourceDomains? }): McpServedResource

// Fold a loom event stream into the payload the template renders. Validates
// `components` against `catalog` (requireRoot: true — this payload is always a
// COMPLETE model, never a delta) and throws exactly as A2UISurfaceBridge does
// (ADR 0088 D5 amendment): the template's renderer returns `null` for an
// unknown component, which was a silent hole in the view.
function surfacePayload(events: readonly EventEnvelope[], options: {
  executionId: string; components: ComponentInstance[];
  redact: readonly string[];        // REQUIRED — see below
  surface?: string; surfaceId?: string;
  catalog?: A2UICatalog;             // default `defaultCatalog`, mirroring A2UISurfaceBridge
}): SurfacePayload   // { surfaceId, executionId, components, dataModel, sequence }

// The app-visibility cursor tool — the View's refresh channel.
function surfaceCursorTool(options: {
  store: EventStore; components: ComponentInstance[]; redact: readonly string[];
  surface?: string; name?: string; authorize?(executionId): boolean | Promise<boolean>;
}): McpServedTool     // mode: "ambient", ui.visibility: ["app"]

type SurfaceCursorResult =
  | { executionId: string; sequence: number; changed: false }
  | (SurfacePayload & { changed: true });

// Decision provenance across a long suspension.
const SEEN_SEQUENCE_FIELD = "seenSequence";
interface SurfaceAction { surfaceId: string; action: string; seenSequence?: number }
function isStale(action: Pick<SurfaceAction, "seenSequence">, currentSequence: number, tolerance?: number): boolean

Pin the template

surfaceHostPin() delegates to @loom/plugin-mcp’s hashResourceBody — the same function the ingress side applies to a fetched resource, so a pin captured at plan time equals what a reader recomputes. Two hashing implementations that mostly agree is how a pin stops detecting the drift it exists to detect (ADR 0063 D1).

Redaction is required, not defaulted

redact has no default. The payload crosses the trust boundary into a rendered iframe, and a list that quietly defaults to empty is a silent leak — “we forgot to configure it” would look identical to “there is nothing to mask”. Pass [] to assert explicitly that nothing needs it. Masking happens on egress only; the fold keeps true values so increment/append/merge stay correct.

No server push — the View polls its own cursor

The extension has no server→View channel after ui/notifications/tool-result. The only seam it offers is repeatable view-initiated tools/call, so the template polls loom_surface_since. This is polling and it is worse than SSE — the extension’s limitation, contained behind one tool. A cursor read returns either { changed: false } or a complete model, never a delta: a tail fold is a partial state that only looks like one (a nested patch yields {a:{b:1}}, which a shallow merge splices over all of a, and a remove is indistinguishable from untouched). The cheap tail read is kept purely for change detection.

Staleness across a long suspension

resume() already refuses stale code (K11), stale input (K11.5), forged approvals (K12.2) and post-cancel resume (K8.2). What it cannot know is which version of the data a human was looking at: the execution knows when it suspended, not what the user saw. So the View echoes seenSequence on every action and isStale is the predicate — evidence, not policy. Whether a lag invalidates a decision is domain-specific; express it as an ADR 0060 staleDecision channel so it is recorded, replay-substituted, and branchable into re-confirmation.

Absent provenance counts as **stale**: an action with no seenSequence is indistinguishable from one made against arbitrarily old data.

import { createMcpServer } from "@loom/plugin-mcp";
import { surfaceCursorTool, surfaceHostResource, surfaceHostPin, isStale } from "@loom/plugin-mcp-app";

const server = createMcpServer({
  resources: [surfaceHostResource({ pin: PINNED })],       // verified before serving
  tools: [
    surfaceCursorTool({ store: eventStore, components: view, redact: ["customer.email"] }),
    { name: "approve", inputSchema: {…}, mode: "journaled",
      ui: { resourceUri: SURFACE_HOST_URI, visibility: ["model", "app"] },
      handler: async (args) => runtime.resume(executionId, { eventType: "TaskApproved", payload: args }) },
  ],
  audienceOf: (req) => (isAppChannel(req) ? "app" : "model"),
  authorizeAppJournaledCall: (tool, req) => verifyUserGrant(req, tool.name),   // default DENY
});
await server.start();

The verdict is computed at the trust boundary, not in the flow. seenSequence is a claim by an untrusted client, and the head sequence it must be judged against is a property of the LOG — which the tool handler owns and ctx does not expose. So the handler decides stale or not and the flow decides what that means:

// In the approve tool's handler — evidence:
const events = await eventStore.read(executionId);          // BEFORE resuming; a resume moves the head
const headSequence = events.at(-1)?.sequence ?? 0;
await runtime.resume(executionId, {
  eventType: "TaskApproved",
  payload: { ...action, stale: isStale(action, headSequence), headSequence },
});

// In the flow, at the gate — policy:
const signal = (await ctx.suspend({ on: "TaskApproved" })) as { stale: boolean; seenSequence?: number };
if (signal.stale) {
  // This gate refuses; another flow could legitimately accept. Declare
  // `staleDecision` on the step to make it a recorded, branchable outcome.
  surface(ctx).set("status", "re-review");
}

A runnable end-to-end wiring — real MCP server, real client, and the published template executed in a DOM — is mcp-app-release-approval in the sibling loom-examples repo.

@loom/blueprint-runtime — declarative UI

function blueprint$(config: { id: string; title: string; layout: Layout; projections: string[]; projectionVersions?: Readonly<Record<string, string>> }): Blueprint

// Layout helpers — a grid child is a GridCell (IR 2.0.0); grid() and asCell() normalise a bare
// Widget | Layout to { span: 1, node } so every existing call keeps compiling
grid({ columns: number; id?: string; gap?: number; title?: string; role?: string; children: Array<GridCell | Widget | Layout> }): GridLayout
flex({ direction?: "row" | "column"; id?: string; gap?: number; title?: string; role?: string; children }): FlexLayout
stack({ id?: string; gap?: number; title?: string; role?: string; children }): StackLayout
// every Layout has title?: string — a section heading (IR 2.1.0, ADR 0098)
// every Layout has role?: string — the open container axis, as Widget.kind is for widgets
// (IR 2.2.0, ADR 0099); grid/flex/stack take role? and omit it when absent. Never ARIA, never text.
function isLayoutRole(value: unknown): value is string   // /^[a-z][a-z0-9-]{0,63}$/; else ⇒ plain container
// every Layout has id?: string — container identity (IR 2.4.0, ADR 0101): Widget.id's rule (non-empty
// string), ONE namespace with widget ids; a renderer that reconciles keys the container by it.
// Absent or malformed ⇒ keyed positionally. The builders omit it when absent.
function isLayoutId(value: unknown): value is string     // non-empty string (Widget.id's rule); else ⇒ absent
interface GridCell { span?: number; row?: number; rowSpan?: number; column?: number; node: Widget | Layout }
// span = columns occupied (default 1); row/column = 1-based start lines, rowSpan = rows (default 1);
// all placement absent = flow placement (IR 2.1.0, ADR 0098)
// cross-renderer reading order is guaranteed only when EVERY cell of a grid has both row and
// column, or none has any placement; a PARTIALLY placed grid is auto-placed by CSS in React and
// sorted absent-last in the terminal, and the two can differ (ADR 0098 D4/D6)
function cell(span: number, node: Widget | Layout, placement?: { row?: number; rowSpan?: number; column?: number }): GridCell
function asCell(child: GridCell | Widget | Layout): GridCell   // the one place a legacy bare child is wrapped
function isWidget(node: unknown): node is Widget               // the leaf/container discriminator every walker branches on

// Widget factories → Widget (each binds via { projectionId, path })
MetricCard, Timeline, ApprovalCard, StatusBadge, ProgressBar, DataTable, AlertCard, StreamingText
customWidget$({ kind: string; title?; binding?; props?; id? })   // bring-your-own widget kind

type BindingState = "pending" | "ready" | "stale" | "error" | "empty"   // empty: IR 2.3.0, ADR 0100 — delivered, nothing to show; not an error
interface BindingStatus { state: BindingState; seq?: number; finding?: { code: string; message: string } }
interface DataBinding {
  projectionId: string; path: string;
  status?: { path: string };       // same projection state as the value; absent ⇒ no status channel
  sensitivity?: DataClass;         // @loom/classify; read by compilers/delivery, never by renderers
}
interface WidgetSource { type: string; version: string; degradedFrom?: Array<{ type: string; version: string }>; redacted?: true }
// redacted (IR 2.5.0, ADR 0097 amendment): content withheld for the surface's audience; carries { type: "redacted", version: "1" }, no binding, no degradedFrom (validateBlueprint: redacted-invalid); renderers draw only the constant marker "⊘ redacted"
// Widget gains: source?: WidgetSource                          // never in props
// Blueprint gains: irVersion?: string                          // absent ⇒ "1.0.0"; writers always set it
function resolveWidgetStatus(widget: Widget, stateMap: Map<string, unknown>): BindingStatus | undefined
function describeWidgetStatus(widget: Widget, value: unknown, status: BindingStatus | undefined, check?: WidgetValueCheck): WidgetStatusDescriptor
// the status matrix (redacted > error > empty > contract > stale > pending/loading > ready) as one pure decision the React, terminal and DOM renderers draw from (ADR 0127);
// tag: "inert" | "redacted" | "error" | "empty" | "stale" | "pending" | "loading" | "ready", plus message, finding, hasValue, showBody, degradedChain; a malformed source.degradedFrom is { tag: "invalid" } (never a throw)
function describeWidgetFrame(widget: Widget, d: Exclude<WidgetStatusDescriptor, { tag: "invalid" }>): WidgetStatusFrame
// what every surface says about a decided status (ADR 0127): data-binding-state, the marker (name, ARIA role alert|status, glyph, line, tooltip, block|inline), data-source-*, the degraded chain and its tooltip; renderers draw it in their own medium and add no vocabulary
function upgradeBlueprint(bp: Blueprint): Blueprint   // current-major artifact from any readable one; identity on 2.0.0 through 2.5.0 input
function blueprintIrVersion(bp: Blueprint): string    // bp.irVersion ?? "1.0.0"

// The Blueprint is a declared rendering IR (ADR 0096; 2.0.0 by ADR 0097, 2.1.0 by ADR 0098, 2.2.0 by ADR 0099, 2.3.0 by ADR 0100, 2.4.0 by ADR 0101, 2.5.0 by the ADR 0097 amendment): bump on any
// shape change; a test pins the shape digest per version
const BLUEPRINT_IR_VERSION: string; const BLUEPRINT_IR_SHAPE: Record<string, readonly string[]>

// Framework-agnostic traversal (renderers are built on this)
function traverseBlueprint<TNode>(node: Layout | Widget, stateMap: Map<string, unknown>, options: {
  renderWidget: (ctx: WidgetRendererProps) => TNode;                       // WidgetRendererProps.status?: BindingStatus
  renderContainer: (ctx: { layout: Layout; children: TNode[]; cells?: Array<{ span: number; row?: number; rowSpan?: number; column?: number; child: TNode }> }) => TNode;
  onAction?: (action: WidgetAction) => void;
}): TNode
function traverseBlueprintAsync<TNode>(node, stateMap, options): Promise<TNode>   // interruptible, for BYOR renderers
// traverseBlueprintAsync's createContainer gets the raw Layout only (no shaped cells/placement
// structure — the push model creates a container before its children resolve, so there is
// nowhere to hand per-cell placement alongside them); only the sync traverseBlueprint's
// renderContainer hands a ContainerContext.cells surface (ADR 0098)
function resolveWidgetValue(widget: Widget, stateMap: Map<string, unknown>): unknown
class RendererRegistry<TRenderer> { register(kind, renderer): this; get(kind); has(kind); kinds(); merge(other) }

Conformance, identity and pins. A blueprint a person writes is checked by the type system; one a model composes at runtime is data. These are the three checks over that data — all of them report findings and none of them throw, because a composer must be able to degrade and record a gap rather than lose the surface.

// ADR 0088 — is every component one the audited catalogue defines, and can the renderer draw them?
function defineWidgetCatalog(catalog: WidgetCatalog): WidgetCatalog   // { id, entries: { kind, props, binding }[] }
const builtinCatalog: WidgetCatalog                                   // loom's own eight widgets, NOT a mandate
function validateBlueprint(blueprint: Blueprint, catalog: WidgetCatalog): BlueprintValidation
// findings: unknown-kind | missing-binding | unexpected-binding | undeclared-prop |
//           undeclared-projection | duplicate-node-id      · `widgets` is the DENOMINATOR
//           + IR checks: span-out-of-range | placement-out-of-range | columns-invalid | malformed-node | …
//           + role-invalid (warn; a present role failing isLayoutRole — IR 2.2.0, ADR 0099)
//           + layout-id-invalid (warn; a present layout id that is not a non-empty string — IR 2.4.0, ADR 0101)
//           duplicate-node-id spans widget AND layout ids (IR 2.4.0); a layout-level finding's
//           nodeId is the layout's valid id, else "<layout>"
function catalogCoverage(catalog: WidgetCatalog, registry: RendererRegistry<unknown>): CoverageReport   // { ok, missing, extra }

// ADR 0094 — which node ids did this change DROP? (the failure one tree cannot show)
function blueprintNodeIds(blueprint: Blueprint): string[]      // WIDGET ids only, pre-order
function blueprintLayoutIds(blueprint: Blueprint): string[]    // valid LAYOUT ids, pre-order, root included (IR 2.4.0, ADR 0101)
function preservesNodes(before: Iterable<string>, after: Iterable<string>): IdentityReport
function preservesBlueprintNodes(before: Blueprint, after: Blueprint): IdentityReport   // widget ids ∪ layout ids
// id SETS, not blueprints: the second consumer (A2UISurfaceBridge.identityDiff) has a flat list

// ADR 0095 — does this view still MEAN what it meant? A binding freezes a NAME
function blueprintPins(blueprint: Blueprint): VersionPins                       // what it recorded
function checkBlueprintPins(blueprint: Blueprint, current: VersionPins): PinReport
// `Blueprint.projectionVersions` is part of the value, hence part of the content address

*Types re-exported from @loom/core~* so a consumer types the report from the package it already imports: ~PinReport, PinFinding, PinFindingCode, VersionPins.

@loom/plugin-renderer-react

function BlueprintRenderer(props: {
  blueprint: Blueprint; projectionRuntime: ProjectionRuntime; executionId: string;
  registry?: RendererRegistry<WidgetRenderer>; onAction?: (a: WidgetAction) => void;
  check?: WidgetValueCheck;  // ADR 0097 — value-contract slot between `error` and `stale`; both render paths
  fiber?: FiberConfig;   // opt-in render-side fiber; default off
}): JSX.Element

function LoomProvider(props: {
  projectionRuntime: ProjectionRuntime; workflowRuntime: WorkflowRuntime;
  serializedExecutionState?: string; serializedProjectionState?: string;
  fiber?: FiberConfig; children: React.ReactNode;
}): JSX.Element
function useLoom(): { projectionRuntime; workflowRuntime; preloadedExecutionState; fiber? }

function useProjection<TState>(pr: ProjectionRuntime, projectionId: string, executionId: string): TState
function useDeferredProjection<TState>(pr, projectionId, executionId): TState   // marks streamed updates non-urgent
function useAgentSurface(runtime: AgentSurfaceRuntime, executionId: string, surface?: string): SurfaceState

type FiberConfig = boolean | { granularUpdates?: boolean; suspense?: boolean; deferStreaming?: boolean }; // all default off
const defaultRegistry: RendererRegistry<WidgetRenderer>;  // MetricCard/Timeline/ApprovalCard/StatusBadge/StreamingText/ProgressBar/DataTable/AlertCard

// IR 2.0.0 (ADR 0097) — the canonical binding-status matrix, applied around a widget's rendered
// body. Renders NO wrapper element when the widget has neither a declared status channel
// (binding.status) nor a source — a 1.0.0 artifact's DOM is unchanged.
// IR 2.1.0: a placed grid cell gets explicit grid-column/grid-row; a titled layout renders <section><h2/>…</section>
// IR 2.2.0: Layout.role is ignored — a roled layout's DOM is the unroled one's
// IR 2.4.0: a valid Layout.id is the container element's React key (eager and fiber) — the DOM is unchanged,
//           and a container inserted/reordered around it keeps its widgets' DOM; absent ⇒ index key, as before.
//           Grid cell keys are `k:<child key>` / `i:<index>` (disjoint), a span-1 cell in a keyed Fragment — DOM unchanged
// IR 2.3.0: status empty → data-binding-state="empty" + <div role="status" data-binding-marker="empty">○ title: no data</div>
//           (finding.message when present), ranked after error; the value and the check are skipped
function WidgetFrame(props: { widget: Widget; value: unknown; status?: BindingStatus; check?: WidgetValueCheck; children: React.ReactNode }): JSX.Element
// The caller-supplied value contract. Runs ONLY for a widget whose binding declares a status
// channel and only when status?.state is neither "error" nor "empty"; a finding renders as the error row, outranking
// stale/pending/ready. loom validates nothing about a bound value itself.
type WidgetValueCheck = (widget: Widget, value: unknown) => { code: string; message: string } | undefined
// Fiber selector over { value, status } together — sibling to useWidgetValue; a status-only
// change re-renders without forcing an extra render on a value-only change, and vice versa
function useWidgetSlice(runtime: ProjectionRuntime, widget: Widget, executionId: string, enabled?: boolean): WidgetSlice   // WidgetSlice = { value: unknown; status?: BindingStatus }

Built-in React renderers: MetricCardRenderer, TimelineRenderer, ApprovalCardRenderer, StatusBadgeRenderer, StreamingTextRenderer. (ProgressBarRenderer, DataTableRenderer and AlertCardRenderer exist only inside defaultRegistry, not as exports.)

@loom/plugin-renderer-terminal

// `status` is optional (ADR 0097): existing two-argument renderers keep compiling and keep
// their output for widgets with no declared status channel
type TerminalWidgetRenderer = (widget: Widget, value: unknown, status?: BindingStatus) => string
type WidgetValueCheck = (widget: Widget, value: unknown) => { code: string; message: string } | undefined
interface TerminalRenderOptions {
  registry?: RendererRegistry<TerminalWidgetRenderer>  // absent → kind-blind `title: value`; present → registered kinds render, unregistered kinds are marked `[kind]`
  check?: WidgetValueCheck                             // ADR 0097 — value-contract slot between `error`/`empty` and `stale`, declared channels only
}
const defaultTerminalRegistry: RendererRegistry<TerminalWidgetRenderer>                 // covers the eight built-in kinds (asserted by catalogCoverage)
// applies the canonical status matrix (glyphs ⋯ pending AND loading / ~ stale / ✗ error / ○ empty) and a
// degraded-from suffix from `source` on the first line; byte-identical output for a widget with
// neither a declared channel nor a source
// IR 2.1.0: placed grid cells print in reading order (row, then column); a titled layout prints an underlined heading
// IR 2.2.0: Layout.role is ignored — output is byte-identical with or without one
// IR 2.4.0: Layout.id is ignored — output is byte-identical with or without one
// IR 2.3.0: status empty → `○ <title>: <finding.message ?? "no data">`, ranked after error; value and check skipped
function renderBlueprintToString(blueprint: Blueprint, stateMap: Map<string, unknown>, options?: TerminalRenderOptions): string
class TerminalRenderer { constructor(projectionRuntime: ProjectionRuntime, options?: TerminalRenderOptions); render(blueprint: Blueprint, executionId: string): string }

@loom/plugin-renderer-dom

// Framework-free DOM renderer (ADR 0127). Depends on blueprint-runtime and projection-runtime only.
interface DomWidgetContext { doc: Document; widget: Widget; value: unknown; status?: BindingStatus; onAction?: (a: WidgetAction) => void }
type DomWidgetRenderer = (ctx: DomWidgetContext) => HTMLElement   // returns the widget BODY; the adapter owns the status frame
interface DomRenderOptions {
  registry?: RendererRegistry<DomWidgetRenderer>   // default defaultDomRegistry; an unregistered kind draws "[No renderer for X]"
  check?: WidgetValueCheck                         // value-contract slot between error/empty and stale, declared channels only
  onAction?: (action: WidgetAction) => unknown     // { type, widgetId, payload } — no viewedVersion stamping (that is loom-ui)
  title?: "heading" | "none"                       // root <h1>, default "heading"
  document?: Document                              // default host.ownerDocument, else globalThis.document
}
interface MountOptions extends DomRenderOptions { projectionRuntime: ProjectionRuntime; executionId: string }
interface DomUpdateReport { identity: IdentityReport; remounted: readonly string[]; outcome: "updated" | "recovered" | "failed" | "busy" | "disposed"; error?: string }   // remounted: present before AND after but rebuilt (kind change, moved container)
interface DomHandle { element: HTMLElement; update(next: Blueprint): DomUpdateReport; dispose(): void }   // update never throws
const defaultDomRegistry: RendererRegistry<DomWidgetRenderer>   // the eight built-in kinds (asserted by catalogCoverage and the three-way parity test)
// one-shot, not live — the sibling of renderBlueprintToString
function renderBlueprintToDom(blueprint: Blueprint, stateMap: Map<string, unknown>, options?: DomRenderOptions): HTMLElement
// live: subscribes each bound projection; a change re-draws only widgets bound to it whose value/status changed.
// update(next): widgets keyed by id, containers by layout id (IR 2.4.0) or ordinal+type; matched nodes are kept, moved only if out of place
function mountBlueprint(host: HTMLElement, blueprint: Blueprint, options: MountOptions): DomHandle
// for custom renderers — the only way this package builds elements (no innerHTML anywhere)
function h(doc: Document, tag: string, attrs?: Attrs, ...children: Child[]): HTMLElement   // string child = text node; unlisted tag/attr THROWS; href checked by safeUrl
function safeUrl(url: unknown): string | undefined    // http, https, mailto, tel or relative; javascript:/data:/protocol-relative and disguised schemes -> undefined
function display(value: unknown): string              // text for a value (objects as JSON)
function token(value: unknown, fallback: string): string   // a short lowercase token usable as a class suffix, else fallback
// IR 2.1.0: titled layout -> <section aria-label><h2>; placed grid cells get grid-column/grid-row on the child (no wrapper)
// IR 2.2.0: Layout.role is ignored (no container registry; that is loom-ui)
// IR 2.3.0: status empty -> <div role="status" data-binding-marker="empty">○ title: no data</div>

Built-in DOM renderers: the eight kinds MetricCard, Timeline, ApprovalCard, StatusBadge, StreamingText, ProgressBar, DataTable, AlertCard. Not exported from @loom/sdk.


Agentic

@loom/llm — provider-agnostic models

interface LlmMessage { role: "system" | "user" | "assistant"; content: string | LlmContentPart[]; }   // R5 (ADR 0120)
interface LlmRequest { messages: LlmMessage[]; tools?: LlmToolSpec[]; system?: string; maxTokens?: number; temperature?: number; signal?: AbortSignal; }
interface LlmResponse { content: string; toolCalls?: LlmToolCall[]; stopReason: "end_turn" | "tool_use" | "max_tokens"; usage?: { inputTokens: number; outputTokens: number }; }
interface LlmProvider { readonly capabilities?: LlmCapabilities; complete(request: LlmRequest): Promise<LlmResponse>; stream?(request: LlmRequest): AsyncIterable<LlmStreamChunk>; }

class AnthropicProvider { constructor(config?: { apiKey?: string; model?: string; baseUrl?: string; capabilities?: LlmCapabilities; headers?: Readonly<Record<string, string>>; thinking?: { type: "adaptive"; display?: "summarized" | "omitted" } }) }  // model default "claude-opus-4-8"; thinking -> the request's `thinking` (ADR 0133), unset = not sent
class OpenAiProvider    { constructor(config?: { apiKey?: string; model?: string; baseUrl?: string; capabilities?: LlmCapabilities; headers?: Readonly<Record<string, string>> }) }  // model default "gpt-4o"
class GeminiProvider    { constructor(config?: { apiKey?: string; model?: string; baseUrl?: string; capabilities?: LlmCapabilities; headers?: Readonly<Record<string, string>>; includeThoughts?: boolean }) }  // model default "gemini-2.0-flash"; includeThoughts -> generationConfig.thinkingConfig (ADR 0133)
// also on the configs: maxResponseBytes? / maxStreamBytes? / maxLineBytes? (all three), GeminiProvider thinkingBudget?: number, OpenAiProvider reasoningEffort?: "none" | "minimal" | "low" | "medium" | "high"
// `headers` (all three providers): extra request headers sent on every complete/stream call, e.g. `{ "anthropic-workspace-id": "wrkspc_..." }` for an Anthropic key not scoped to a workspace. Validated at construction (strings, no CR/LF); a header the provider sets itself (x-api-key, authorization, anthropic-version, x-goog-api-key, content-type) cannot be overridden and throws (case-insensitive; the error names the header, never the value). Unset = requests unchanged.

function createLlmEffect<TIn = string, TOut = string>(options: {
  provider: LlmProvider;
  buildRequest: (input: TIn, ctx: EffectContext) => LlmRequestInput;   // LlmRequest whose text slots may also carry LlmContent (tainted values)
  parseResponse?: (response: LlmCapturedResponse, input: TIn) => TOut;   // default: response.content; LlmCapturedResponse = LlmResponse with reasoning reduced to LlmReasoningPart[]
  stream?: boolean; onDelta?: (delta: string, ctx: EffectContext) => void; onReset?: (attempt: number, ctx: EffectContext) => void;
  guard?: (ctx: EffectContext) => (progress: StreamProgress) => void;   // e.g. streamTokenBudget
  resolveContent?: ContentResolver;   // R5 — (ref, { executionId, stepId, principal, origin, maxBytes, signal }) => bytes | null; MUST authorize; live calls only
  maxInlineBytes?: number; maxInlineTotalBytes?: number; maxResolvedBytes?: number;   // R5 — caps (default 64 KiB per part / 256 KiB inline per request / 32 MiB resolved)
  onToolDelta?: (d: { id: string; name?: string; inputDelta: string }, ctx: EffectContext) => void;   // R5 — transient
  onToolReset?: (attempt: number, ctx: EffectContext) => void;   // R5 — a retry begins: discard the fragments so far
  reasoning?: { store?: ReasoningStore; capture?: boolean | ((ctx) => boolean); live?: boolean; model?: string;
                onDelta?: (delta: TaintedValue<string>, ctx: EffectContext) => void };   // ADR 0133 — model reasoning as audit evidence
}): LlmEffect<TIn, TOut>

*Content parts and tool turns (R5, ADR 0120).* A message’s content is a string (text only, unchanged) or an array of parts: { type: "text" }, { type: "image" | "audio" | "file", mediaType, source } with source either { kind: "ref", ref: { address, size? } } (a ContentStore claim-check) or { kind: "data", data } (base64, at most MAX_INLINE_BYTES = 64 KiB), { type: "tool_use", id, name, input } (assistant messages) and { type: "tool_result", toolUseId, content, isError? } (user messages; there is no tool role). textOf / toParts read either form (textOf throws TaintedContentError on a tainted() wrapper at the top level, in a text part or in tool_result content); assistantMessageOf(response) builds the assistant turn a tool result answers. A provider declares capabilities ({ image?, audio?, file? }, each a list of media types, "image/*" and "*" allowed); absent means text and tool calls only, and assertRequestSupported throws UnsupportedModalityError before any network call (createLlmEffect, AgentRunner, ReasoningRuntime and each shipped provider all run it). guardRequest also enforces structure: tool_use only in assistant, tool_result only in user and only for an earlier tool_use id, media only in user, well-formed media types, inline data under the cap (InvalidLlmRequestError, OversizedInlinePartError); OversizedInlinePartError.scope is "part" (the per-part cap) or "request" (the per-request inline total); a media part marked untrusted: { origin } is bracketed by the same <untrusted-input> fence as text and refused outside user. resolveRequestContent / ContentResolver turn refs into bytes inside the effect (a resolver authorizes against a SET of owning tenants, since content-addressed dedupe makes identical bytes one blob; ADR 0120 “Authorizing under dedupe”; UnresolvedContentError when absent or over maxResolvedBytes); the journaled request carries the address only. Streaming adds LlmStreamChunk.toolDelta (tool-call input fragments; transient; the assembled call still arrives on the terminal chunk). Exported: LlmContentPart and the part types, LlmCapabilities, MediaTypeSet, UnsupportedModalityError, InvalidLlmRequestError, OversizedInlinePartError, UnresolvedContentError, TaintedContentError, MAX_INLINE_BYTES, MAX_RESOLVED_BYTES, assertRequestSupported, mediaTypeAccepted, capabilitiesForModel(model, base, allow) (the allowlist the shipped providers use: base only when the model id matches, else {}), GEMINI_CAPABILITIES, GEMINI_MEDIA_MODELS, requestMediaParts, resolveRequestContent, inlineData, validateMediaPart, textOf, toParts, hasStructuredParts, assistantMessageOf, base64ByteLength. In @loom/agent: mediaResult(parts) (what a tool declared returnsMedia: true returns to hand the model an image/file; never honoured for a tainted output), isMediaResult, toolResultPart, and createAgentModelEffect(provider, { resolveContent, onToolDelta }) / AgentConfig.resolveContent.

Model reasoning (ADR 0133). LlmResponse.reasoning / the terminal LlmStreamChunk.reasoning carry LlmReasoning { kind: "text" | "summary" | "redacted"; text?; signature?; provider } as the provider returned it; LlmToolCall.signature carries a call’s own continuation bytes; capabilities.reasoning says a provider can return reasoning. createLlmEffect reduces it before parseResponse to LlmReasoningPart { type: "reasoning"; kind; provider; signature?; textRef?; digest?; omitted?: true } (readable text goes to reasoning.store, a ReasoningStore { put, get }, never into a step value; a request-time text is added only to send a part back) and emits the userspace LLM_REASONING_CAPTURED event (references and digests). A failing store put fails the step. readReasoning(log, store, { executionId, stepId?, stepPrefix? }) is the audit reader (readReasoningBounded adds maxParts / maxBytes caps and truncated, applied before any blob is fetched) (per step, per attempt, per part ok / redacted / erased / digest-mismatch; text returned tainted). reasoning.onDelta is the configured live sink (guarded; used with live: true). Exported: LlmReasoning, LlmReasoningPart, LlmReasoningKind, LlmReasoningOptions, ReasoningStore, LLM_REASONING_CAPTURED, captureReasoning, readReasoning, readReasoningBounded, reasoningStoreResolver, textFreeReasoning, layoutOf, LlmLayoutEntry, reasoningDigest, withoutReasoningParts, hasReasoningParts. In @loom/agent: createAgentModelEffect(provider, { reasoning }) and registerReasoningRoutes(effects, reasoning, { reasoning }) thread the store; agentLoop$ carries the parts (and tool signatures) on the assistant turn and drops every reasoning part on compaction and another route’s parts on a model switch. defineConfig({ observability: { reasoning } }) sets the default ({ store?, capture?, live?, requireContinuation? } | false); requireContinuation: true makes agentLoop$’s direct routed path (no reasoningToolId) throw AgentLoopConfigError for a reasoning-capable route instead of warning (also AgentLoopConfig.requireReasoningContinuation / onWarn, and configureAgentLoopDefaults). With reasoning on the transcript, compaction is the simple shape (one user message of summary + the in-flight round’s results).

Providers talk to vendor HTTP APIs over fetch — no vendor SDK dependency. In tests, use the VCR providers from undefined.

@loom/reasoning — model routing & elevation (2.0)

Routes across a registry of models and elevates to a stronger one when confidence is low; every route/elevation is logged via the decision seam (P9) when a DecideFn is passed.

interface ModelRoute { id: string; provider: LlmProvider; tier: number; tags?: string[]; }   // tier 0 = cheapest
interface ReasoningRequest { messages: LlmMessageInput[]; system?: LlmContent; strategy?: string; tools?: LlmToolSpec[]; maxTokens?: number; temperature?: number; signal?: AbortSignal; maxTier?: number; }
interface ReasoningResult  { content: string; toolCalls?: LlmToolCall[]; stopReason: LlmResponse["stopReason"]; usage?; modelId: string; confidence: number; elevated: boolean; trace: Array<{ modelId: string; tier: number; confidence: number; elevated: boolean }>; }
type DecideFn = <T>(decisionId: string, decider: () => T | Promise<T>) => Promise<T>;          // pass ctx.decide

interface ReasoningRuntimeConfig { routes: ModelRoute[]; router?: Router; confidence?: ConfidenceSignal; elevateThreshold?: number; cache?: ReasoningCache; }  // elevateThreshold default 0.5
class ReasoningRuntime {
  constructor(config: ReasoningRuntimeConfig)
  reason(req: ReasoningRequest, opts?: ReasonOptions): Promise<ReasoningResult>   // ReasonOptions: { decide?, invoke?: ModelInvoker, cacheScope?, wrapCall?, signal?, decisionKey? } + (resolveContent AND resolveContext, together)
  get availableRoutes(): readonly ModelRoute[]
}

// Routers (pick a route): tagRouter (cheap-first, default) · fixedRouter(id) · rankedRouter(rankings)  // rankedRouter = M10 learned; also ucb1, epsilonGreedy
// Confidence signals (pluggable, NOT a magic number): stopReasonConfidence (default) · validatorConfidence(fn) · constantConfidence(n)
interface ConfidenceSignal { score(req: ReasoningRequest, res: LlmResponse): number | Promise<number>; }
interface Router { select(req: ReasoningRequest, routes: ModelRoute[]): ModelRoute; }

ReasoningResult is a superset of LlmResponse, so it is a drop-in for provider.complete callers. @loom/intent and @loom/planner accept an optional reasoning and prefer it. See reasoning & memory (loom/docs/loom-2.0/02-reasoning-and-memory.md).

@loom/agent — ReAct loop

/** @deprecated R10 — use agentLoop$ (ADR 0121). */
class AgentRunner {
  constructor(config: {
    definition: AgentDefinition;
    provider?: LlmProvider;         // (2.0) provider OR reasoning — one is required
    reasoning?: ReasoningRuntime;   // (2.0) prefer routing/elevation; routes logged (DECISION_RECORDED source:"agent")
    effectRuntime: EffectRuntime;
    maxIterations?: number;         // default 10
    systemPrompt?: string; memory?: MemoryRuntime;
    parallelTools?: boolean | number;  // default false; true = all, number = cap
    skills?: SkillRegistry; skillSelector?: SkillSelector;
    resolveContent?: ContentResolver; stream?: boolean; onDelta?: (delta: string) => void;
    acceptAtLeastOnce?: boolean;    // K9.3 (ADR 0021): default false = REFUSE an idempotent:false tool (IdempotentToolRefusedError, terminal, checked for the WHOLE turn's batch before any call is dispatched); true = run it with at-least-once re-run on recovery
  })
  // each nested tool call gets ctx.idempotencyKey = `<outer>:t<turn>:c<position>:<first 16 hex of sha256(name + canonical input)>`
  run(input: AgentInput, ctx: EffectContext): Promise<AgentOutput>
  asEffect(): LlmEffect<AgentInput, AgentOutput>
}
interface AgentInput  { prompt: string; context?: unknown; memoryScope?: string;
                       history?: AgentHistoryMessage[];  // (R17, ADR 0122) earlier turns BEFORE the prompt, oldest first; caller-sized
                       promptOrigin?: string; }           // (R17) the prompt is third-party content: tainted with this origin, fenced at the prompt boundary; not with `context`
interface AgentHistoryMessage { role: "user"|"assistant"; content: string; origin?: string /* taint origin; a tainted assistant message is refused */ }
interface AgentOutput { content: string; toolCallLog: AgentStep[]; iterations: number; }
// AgentStep = { iteration, toolName, toolInput, toolOutput? } by default (toolCallLog: "full"). With agentLoop$ `toolCallLog: "lean"` (ADR 0142) toolOutput is ABSENT and the entry is LeanAgentStep { iteration, toolName, toolInput, status: "ok"|"error", bytes, digest /* sha256 hex of canonicalJson(output), or null */, stepId /* "<loop>#turn:<i>:tool:<j>", the tool frame */ }; narrow with isLeanAgentStep(step)

/** @deprecated R10 — use agentLoop$ (ADR 0121). */
function agentStep$(config: { id: string; name?: string; agent: AgentDefinition; provider: LlmProvider; effectRuntime: EffectRuntime; maxIterations?: number; systemPrompt?: string }): StepDefinition<AgentInput, AgentOutput>

class SkillRegistry { register(skill): this; get(id); all(); ids(); find(query): SkillMatch[] }   // deterministic keyword scoring
function deterministicSkillSelector(maxSkills?: number): SkillSelector
function llmSkillSelector(provider: LlmProvider, maxSkills?: number): SkillSelector      // calls the provider DIRECTLY (outside authz/metering/kill switch; guardRequest runs)
function llmSkillSelector(options: { toolId: string; maxSkills?: number }): SkillSelector // (R9) effect-backed: agentLoop$ only; a journaled frame `<agent>#skills:select`
function agentLoop$(ctx: ExecutionContext, config: AgentLoopConfig, input: AgentInput): Promise<AgentOutput>   // THE canonical loop; throws AgentLoopConfigError for the config conflicts it names; other invalid options throw Error / RangeError
//   AgentLoopConfig base keys: definition: AgentDefinition (required); modelToolId?; reasoning?: ReasoningRuntime; reasoningToolId?; systemPrompt?; maxIterations?;
//     memory?: TieredMemory (+ memoryTier?, memoryScope?, recallLimit?); skills?: SkillRegistry; skillSelector?; approvalGate?; bindApproval?; versions?; cacheScope?; onWarn?
// agentLoop$ config (R10, ADR 0121 — THE canonical loop; AgentRunner and agentStep$ are @deprecated):
//   needsApproval?: (toolCall, turn, onMenu?) => boolean — `true` PARKS the call on `approvalGate` for a human before it runs, `false` runs it (a policy that returns nothing parks). Composes with `kind: "human"` tools exactly as requireApproval did
//   requireApproval?: (toolCall, turn, onMenu?) => boolean — @deprecated alias of needsApproval with the INVERTED polarity (auto-approve: `true` = proceed, `false` = park); same recorded log; one LOOM_AGENT_REQUIRE_APPROVAL_DEPRECATED warning per process; both given = AgentLoopConfigError
//   toolErrors?: "feedback" | "fail"   — default feedback: a failing tool is an isError tool_result (authority/safety/quota/cancel/park never are)
//   parallelTools?: boolean | number   — one turn's calls through ctx.parallel (sequential when the turn holds a `kind: "human"` tool); ids `<agent>#turn:<i>:tool:<j>` unchanged; approvals first, in order
//   compaction?: { toolId: string; afterMessages?: number; afterBytes?: number; keepRecent?: 4 } — fold older turns into one summary through the journaled effect `toolId` ({ messages } -> { summary }); at least one threshold; OPT-IN
//   warnTranscriptBytes?: number | false — with NO compaction, ONE warning per run (onWarn, else LOOM_AGENT_STATE_GROWTH) when the transcript passes this many bytes; default DEFAULT_STATE_GROWTH_WARN_BYTES (256 KiB); report-only (ADR 0137)
//   createSummarizerEffect(provider, { instruction?, maxTokens? = 1024 }) — a ready-made compaction effect (SummarizerEmptyError on an empty summary); register it, it is journaled once
//   toolCallLog?: "full" | "lean"   — (ADR 0142) default full = today's shape; lean = status/bytes/digest/stepId instead of toolOutput (also in the agentInterrupted payload). An explicit value is journaled and a resume with the other value throws AgentLoopConfigError; unset is not checked
//   loopId?: string                    — (R17) the id prefix of this loop's frames/decisions/gates; default definition.id. Two loops in one execution must not share it
//   signal?: AbortSignal               — stop THIS run: no further model/tool call; fails on AGENT_INTERRUPTED with InterruptedAgentRun
//   turnTimeoutMs?, toolTimeoutMs?     — frame `timeoutMs` (model failure fails the run; a tool timeout is an isError result under toolErrors "feedback", fails the run under "fail")
//   input.context must not hold taint at any depth: UntrustedContextError, before any model call
//   writeBack?: false | AgentLoopWriteBack — { tier?, scope? } plus one of toolId / unless / allowDirect: true (the type requires it; an untyped `true` or `{}` throws AgentLoopConfigError) — with `memory`: remember prompt + answer on finish; keyed idempotent; toolId = journaled effect (memoryWriteBackEffect); tainted answer if the run saw taint
//   (with `signal`, each abort checkpoint is a recorded decision `<agent>#abort:<site>`; under toolErrors "feedback", with or without `signal`, each non-human tool outcome is one too, `<agent>#turn:<i>:tool:<j>:outcome`; both stamped, excluded from @loom/learning trajectories)
//   `reasoning` without reasoningToolId: the response is a recorded decision `<agent>#turn:<i>:response:<n>` (no re-ask on resume)
// agentLoop$ config (R9): reasoningToolId?: string | ((routeId) => string)   — each `reasoning` model call becomes a journaled
//   ctx.execute frame `<agent>#turn:<i>:call:<n>` (authz, metering, kill switch, guardRequest, recorded on replay);
//   requireEffectRing?: boolean — refuse `reasoning` without reasoningToolId, a non-effect-backed skillSelector, and a `writeBack` without `toolId`.
// agentLoop$ config (R22, ADR 0125): toolSearch?: ToolSearchConfig — an opt-in `search_tools` meta-tool; default OFF, not worth it below ~30 tools
//   interface ToolSearchConfig { catalog?: ToolDefinition[]       // the searchable set the HOST offers; definition.tools + selected skills' tools are ALREADY on the menu
//                                alwaysLoaded?: string[]           // on the menu from turn 0
//                                namespaces?: string[]             // id-prefix allowlist of searchable tools
//                                annotations?: { [toolId]: { tags?: string[]; aliases?: string[] } }   // trusted host vocabulary (use for MCP/A2A tools)
//                                maxPerSearch?: 5 (<=50); maxLoaded?: 20 (<=200); maxSearches?: 25 (<=200); maxMenuChars?: number
//                                searchToolName?: "search_tools"; ranker?: ToolRanker }
//   each search = ONE recorded decision `<agent>#turn:<i>:tool:<j>:search` (source "agent-tool-search"; replay re-ranks nothing); menu is append-only
//   tool$/skill$/capability$ gain `aliases` (tool$ also `tags`): trusted author vocabulary; a remote (jsonInputSchema) tool's own are NOT trusted
// agentLoop$ config (R22 D11, ADR 0125): skillActivation?: SkillActivationConfig — mid-loop `activate_skill({ name })`; default OFF
//   interface SkillActivationConfig { catalog?: "registry" | SkillDefinition[]   // host-registered skills; upfront-selected ones are not offered
//                                     maxActive?: 5 (<=50); maxIndex?: 50 (<=500); maxIndexBytes?: 8192 (<=128 KiB); maxInstructionBytes?: 32768 (<=1 MiB); toolName?: "activate_skill";
//                                     discovery?: "index" | "search" | "both"   // R22b D12: "search"/"both" need toolSearch; skills found via search_tools
//                                     skillRanker?: ToolRanker | ToolRankerFactory                  // R22c D13: ranks SKILLS only (raw skill ids); needs discovery search/both; a custom toolSearch.ranker ranks TOOLS only; page interleaves by rank position, tool first }
//   R22b: search hits for skills = `skills: [{ kind: "skill", id, description }]` beside the unchanged `tools`; share maxPerSearch; never loaded by the search (call activate_skill); recorded as `skills: string[]` in the agent-tool-search decision
//   index of unselected skills in the turn-0 system prompt; the skill's instructions are the TOOL RESULT; its tools join the menu (deduped)
//   each activation = ONE recorded decision `<agent>#turn:<i>:tool:<j>:skill` (source "agent-skill-activation"); an instructions refusal records its verdict `{ skill, refused }` (replay reproduces it); other refusals are typed error results, nothing recorded; re-activating an active skill re-delivers its instructions
//   "registry" makes EVERY registry skill reachable (relevance, not authorization): pass an explicit catalog to restrict
//   R22d D14: an accepted activation records `{ skill, digest }` (sha256 hex of the delivered instructions); a replay / re-delivery whose current text differs returns the typed error `instructions_changed` (tools stay, nothing recorded, process warning LOOM_AGENT_SKILL_CHANGED); rankers are clamped to the `limit` they were asked for
// ToolDefinition.schemaOrigin?: "local" — R22f authorship claim: the Zod inputSchema is loom/host-authored, so toolSpecFor does not sanitize it even on a remote tool (ignored beside jsonInputSchema); A2APeerClient sets it
// ToolDefinition.origin?: "remote" — set by the MCP and A2A plugins; tool search AND toolSpecFor treat origin remote / jsonInputSchema / kind a2a as un-vouched (description withheld in the model-facing spec unless trustJsonSchemaText)
class SkillActivationError; const SOURCE_SKILL_ACTIVATION = "agent-skill-activation"; ToolSearchSession.noteLoaded(tools)
// R22e D15: toolSearch.ranker / skillActivation.skillRanker: ToolRanker | ToolRankerFactory; toolSearch.stopwords?: string[] (query-only, [] disables, <=256)
type ToolRankerFactory = (docs: readonly ToolSearchDoc[]) => ToolRanker   // sync; once per run, first search; throw = search_unavailable (retried next search); replay never calls it
interface ToolSearchDoc { id: string; description?: string; tags: readonly string[]; aliases: readonly string[] }   // what the built-in BM25F indexes (D7 trust applied)
function toolSearchDocs(tools: readonly ToolDefinition[], opts?: { annotations? }): readonly ToolSearchDoc[]; function skillSearchDocs(skills: Iterable<SkillDefinition>): readonly ToolSearchDoc[]; const DEFAULT_STOPWORDS, MAX_STOPWORDS = 256
interface ToolRanker { kind: string; rank(query: string, limit: number): readonly ToolHit[] | Promise<readonly ToolHit[]> }   // ToolHit = { id: string; score: number }
class Bm25ToolRanker implements ToolRanker { constructor(tools: { id; description?; tags?; aliases? }[], opts?: { stopwords?: string[] }); size: number }   // BM25F: name 3, aliases 2, tags 2, description 1
function tokenizeToolText(text, maxTokens): string[]; function toolQueryTerms(query, stopwords?: ReadonlySet<string>): string[]
class ToolSearchSession; const SOURCE_TOOL_SEARCH = "agent-tool-search"; class ToolSearchError
function registerReasoningRoutes(effects: { register(id, effect) }, reasoning: ReasoningRuntime, options?: { prefix?: string; stream?: boolean; reasoning?: LlmReasoningOptions }): (routeId: string) => string

Emits SKILLS_SELECTED / AGENT_TOOL_CALL / AGENT_COMPLETED events via ctx.emit.

Tool menu (every loop: undefined, undefined, undefined). The model sees each tool’s real argument structure: LlmToolSpec.inputSchema is the JSON Schema of the tool’s Zod inputSchema (toolInputJsonSchema; a hand-written converter that degrades to {} for what it cannot express). A tool call the model returns is executed only if its name is on the agent’s menu (the definition’s tools plus the selected skills’ tools) AND its input passes the tool’s inputSchema. Otherwise nothing runs and the model receives a structured result \{ error: { code: "tool_not_on_menu" | "invalid_tool_input", message, issues? } } (issues capped at 5, 200 chars each) so it can recover; the refusal is a pure function of the model output and the static menu and nothing is appended. Replay: an in-flight agentLoop$ execution whose log (written before R4) recorded an off-menu or invalid-input call replays a refusal in its place, so its toolCallLog and later transcript diverge; drain in-flight agentLoop$ executions before upgrading (agentStep$ / AgentRunner are single-step and unaffected). The gate sits after the needsApproval gate (moving it would break resume ordering); the policy receives onMenu as a third argument so a host can return false (or, on the deprecated requireApproval, true) to skip prompting a human for a call that will be refused anyway. A tool with no inputSchema (untyped callers) is not input-validated. Validation is a SHAPE check, not sanitisation: unknown keys pass unless the schema is .strict(), and transformed / coerced / preprocessed values are checked but the RAW input is what runs. Schemas used as tool inputs must be pure: an impure refinement makes refusals nondeterministic on replay. A schema fault (async or throwing refinement) or an input deeper than 64 levels / 100k nodes is a refusal (invalid_tool_input, fixed message), never a throw; echoed tool names and paths are capped at 200 chars. The converter omits what it cannot state without being tighter than the validator: default values, pattern for regexes with flags, a non-coercing z.date(), bigint literals, recursive lazies past their first expansion; a non-object root is sent as a bare {type: object} (non-object tool inputs are validated but not described). Third-party JSON Schema (R4b). ToolDefinition.jsonInputSchema (set by @loom/plugin-mcp from the server’s inputSchema) is sent in place of the Zod-derived schema, after sanitizeJsonSchema: keyword allowlist, depth 16 / 1000 nodes / 64KB, remote $ref dropped, cyclic or oversized refused (a bare {type: object} and a LOOM_AGENT_TOOL_SCHEMA warning per tool; the run continues). The text that remains is constrained, not just capped: property and $defs keys must match ^[A-Za-z0-9_.-]{1,64}$ (else the entry is dropped), enum/const strings are at most 64 chars without control/zero-width/bidi characters, format is from the standard list, pattern must compile, a $ref must name a kept $defs entry, and a 4096-character total budget refuses the whole schema. description/title text, and the tool’s own description (replaced by a fixed “Third-party tool; description withheld”), are stripped unless the operator opts in (registerInto({ trustDescriptions: true }) / trustJsonSchemaText, capped at 1000 chars, warns once per tool): they are instructions to a model and a plain string carries no taint for guardRequest to refuse (ADR 0085). @loom/plugin-mcp skips tools whose name is not ^[A-Za-z0-9_-]{1,64}$. An MCP tool shape-checks its own call (object, required, top-level property types) INSIDE its effect and returns the refusal as the recorded result, so replay never re-evaluates a live schema; deeper validation is the server’s. Exported: buildToolMenu, toolInputJsonSchema, toolSpecFor, sanitizeJsonSchema, validateToolInput.

// F4 — which skills did agentLoop$ select? A pure reader over events (a live log, a replay, an audit export).
function selectedSkillsOf(events: readonly EventEnvelope[], options?: { loopId?: string }): SelectedSkills
interface LoopSkills { upfront: string[]; activated: string[] }   // upfront: selected before turn 0; activated: accepted activate_skill calls, in order
interface SelectedSkills extends LoopSkills { byLoop: Record<string, LoopSkills> }   // top level = deduped union over the loops considered
function classifyModelCallError(err: unknown): "terminal" | "provider" | "other"   // compatibility form of classifyModelCallFailure
function resetAgentLoopDefaults(): void                                             // @internal, TEST-ONLY: clears the process-wide agentLoop$ defaults, including a fail-closed requireReasoningContinuation

@loom/plugin-mcp — Model Context Protocol (client + server)

Client

class McpClient {
  constructor(config: {
    transport: "http"; url: string; headers?: Record<string, string>;
    protocolVersion?: string;   // default DEFAULT_MCP_PROTOCOL_VERSION ("2025-11-25")
  })
  readonly protocolVersion: string   // server's negotiated choice once known, else ours
  connect(): Promise<void>           // handshake; advertises the `io.modelcontextprotocol/ui` extension
  listTools(options?: ListToolsOptions): Promise<MappedMcpTool[]>
  registerInto(effectRuntime: EffectRuntime, options?: ListToolsOptions): Promise<ToolDefinition[]>
  // ListToolsOptions: resultFidelity?: "value" | "full"; trustDescriptions?; anyToolName?; onSkippedTool?;
  // strictToolNames?; taintResults?: wrap each result as untrusted, origin mcp:<serverHost>/<tool>

  // FULL result — every content block, structuredContent, isError, _meta.
  callToolResult(name, input, extraHeaders?): Promise<McpCallToolResult>
  // Convenience projection: prefers structuredContent, else joins+parses text.
  // THROWS on isError (MCP reports tool failures in band, as a successful
  // JSON-RPC response — returning one as a value makes every error a success).
  callTool(name: string, input: unknown, extraHeaders?): Promise<unknown>

  listResources(): Promise<McpResourceDescriptor[]>
  readResource(uri: string): Promise<McpResourceContents[]>
  readUiResource(uri: string): Promise<McpUiResource | undefined>  // carries contentHash provenance
  disconnect(): Promise<void>
}
function mcpTool$(client: McpClient, toolName: string,
                  options?: { resultFidelity?: "value" | "full"; taintResults?: boolean }): StepDefinition<unknown, unknown>

// The ONE result→value projection, and the ONE place an in-band isError throws.
// `callTool` is `projectToolResult(name, await callToolResult(...))`; a caller that
// needs the full result must not re-implement either half.
function projectToolResult(name: string, result: McpCallToolResult): unknown
function throwIfToolError(name: string, result: McpCallToolResult): void

resultFidelity defaults to "value" on every seam, because a step’s output shape is a durable contract — it is what STEP_COMPLETED records, what downstream steps destructure, and what a replay substitutes. "full" is the only shape in which _meta (where MCP Apps hangs ui.resourceUri) and non-text content blocks survive.

Protocol version is declared per request — _meta["io.modelcontextprotocol/protocolVersion"] plus the MCP-Protocol-Version header — and negotiated from the handshake result.

MCP Apps (SEP-1865) ingress — carry, never render

Loom is deliberately not an MCP Apps host (ADR 0072 D2): iframe sandboxing, CSP synthesis, permissions policy and the sandbox-proxy architecture are browser-application concerns where a mistake is an XSS or exfiltration hole. What lives here is the plumbing — recognise a UI-bearing tool, fetch the declared ui:// resource, and carry it with provenance so an application can render it.

const MCP_UI_EXTENSION_ID = "io.modelcontextprotocol/ui";
const MCP_UI_SCHEME = "ui://";
const MCP_UI_MIME_TYPE = "text/html;profile=mcp-app";
const MCP_UI_RESOURCE_URI_META_KEY = "ui/resourceUri";   // DEPRECATED flat form; see below

interface McpUiToolMeta { resourceUri: string; visibility?: ("model" | "app")[] }
interface McpUiResource {
  uri: string; mimeType: string | undefined; text: string | undefined; blob: string | undefined;   // present keys, value may be undefined
  contentHash: string;            // `fnv1a64:<hex16>` — the pin
  meta: McpUiResourceMeta | undefined;   // csp / permissions / domain / prefersBorder
}

function uiMetaOf(meta: unknown): McpUiToolMeta | undefined  // rejects a non-`ui://` resourceUri
function uiFromResult(result: McpCallToolResult): { meta: McpUiToolMeta | undefined; embedded: McpResourceContents[] }
function isAppVisible(ui: McpUiToolMeta | undefined): boolean   // undeclared visibility ⇒ BOTH audiences
function isModelVisible(ui: McpUiToolMeta | undefined): boolean
function hashResourceBody(contents: McpResourceContents): string

A ui declaration whose resourceUri is not a ui:// URI is refused — a UI pointer to an arbitrary scheme is a confused-deputy fetch.

Observable from the log, not just from the client

A dispatch whose result declares a UI emits MCP_UI_OBSERVED — a userspace domain event the fold skips, like MCP_DISPATCHED — so the declaration reaches the log rather than only the client API:

const MCP_UI_OBSERVED = "MCP_UI_OBSERVED";
type McpUiPin = "embedded" | "resolved" | "not-requested" | "unresolvable";
interface McpUiObservedPayload {
  toolName: string; resourceUri: string; contentHash?: string; pin: McpUiPin;
}

The declaration is read from the result’s _meta.ui, then an inlined ui:// content block, then the tool’s static tools/list declaration — decreasing authority. An inlined resource is pinned for free; a declared-but-absent one costs an extra resources/read and is therefore fetched only under registerMcpInvoke(..., { resolveUi: true }). The four pin states exist because “no contentHash” would otherwise be ambiguous between not asked for and *asked for and unavailable*, and a pin is a correctness device. A resource that cannot be fetched never fails the dispatch — the tool call already succeeded and its side effect already happened.

Note the emit is a SEPARATE event rather than a field on MCP_DISPATCHED: that one is emitted before the call so a failed attempt stays indexed for proactive repair, and UI provenance is only knowable after it.

Both meta forms, on both sides

The spec’s form is the nested _meta.ui.resourceUri block, but the reference SDK’s registerAppTool still emits the deprecated flat _meta["ui/resourceUri"] alongside it, so hosts that never migrated keep working. A host reading only the flat key therefore sees *no UI declaration at all* on a server that sends only the nested one, and silently falls back to rendering text — spec-correct and invisible at the same time. So Loom **writes both on egress** (describeTool) and **accepts either on ingress** (uiMetaOf): the nested block wins when the two disagree, and the flat key is held to the same ui:// scheme rule. Both forms go away when the flat one is dropped before GA.

Server

function createMcpServer(options?: {
  tools?: readonly McpServedTool[]; resources?: readonly McpServedResource[];
  port?: number; serverInfo?: { name: string; version: string }; protocolVersion?: string;
  host?: string;                                       // default "127.0.0.1", loopback only
  authenticate?; authorize?(req): boolean | Promise<boolean>;   // with neither, creating the server throws
  insecure?: boolean;                                  // explicitly allow no auth; refused under security.strict
  audienceOf?(req): "model" | "app";                  // default "model"
  authorizeAppJournaledCall?(tool, req, principal?): boolean | Promise<boolean>;  // default DENY
  // also: authorizeTool?(tool, { request, principal }), strict?, runtime?, warn?, onError?(errorId, err)
}): McpServer   // start() / stop() / url

interface McpServedTool {
  name: string; description?: string;
  inputSchema: Record<string, unknown>; outputSchema?: Record<string, unknown>;
  ui?: McpUiToolMeta;      // resource linkage + visibility
  mode?: EffectMode;       // ADR 0073 seam — routes journaled vs ambient
  handler(args: unknown, ctx: McpCallContext): Promise<unknown>;
}

// Serve a registered effect, ROUTED BY ITS DECLARED SEAM: `ambient` dispatches
// through invokeTransient; anything else through the supplied journaled path.
function servedEffect(effectRuntime, toolId, spec): McpServedTool

Two boundaries the server owns:

  • Audience. The extension puts visibility enforcement on the host — the only party that knows whether a tools/call came from the model or a rendered View — so a server cannot derive it from the request. audienceOf is deployment-supplied (a distinct path, port, or credential) and defaults to "model": absent an explicit boundary, app-only tools stay unreachable rather than silently open. An audience-invisible tool is reported as unknown, not forbidden — a “you may not call that” reply confirms the tool exists.
  • Seam. A View can call any tool the host proxies for it. Enrichment reads are harmless; a call that appends to the log is a state transition requested by the user’s UI, so an app-originated call to a journaled tool is denied by default.
Reachability as a REMOTE connector

Two things a hosted client settles before it will talk to a server at all, both handled by the transport:

  • OAuth discovery. A remote host probes /.well-known/oauth-protected-resource (RFC 9728) and friends to learn whether it must authenticate. A server without OAuth answers 404 for the whole /.well-known/ namespace, and answers it above the authorize gate — behind it, a 401 would be equally undecidable (“authenticate, somehow”, with no way to learn how) and the client gives up rather than falling back to connecting unauthenticated. GET on the MCP endpoint itself still returns 405, which is correct for a server offering no SSE stream.
  • Version negotiation. initialize negotiates rather than asserting: a client that names a dated revision gets that same revision back, because this surface — initialize / tools / resources over JSON-RPC — is unchanged across every dated revision, so any well-formed version a client names is one the transport genuinely speaks. A client told a version it did not ask for is entitled to hang up. protocolVersion (the configured default) is the fallback only when the request names nothing usable — absent, "latest", or older than 2024-11-05.

Both are transport behaviour, not configuration: there is nothing to opt into. Neither is theoretical — with them a tunnelled server completes the handshake from a hosted client; without them it is unreachable no matter what it serves.

@loom/plugin-a2a — Agent-to-Agent

// Outbound: register a REMOTE agent as a Loom tool
class A2APeerClient {
  constructor(config: { url: string; headers?; timeoutMs?; pollIntervalMs?; toolId?; taintResults?: boolean; trustDescriptions?: boolean })   // A2APeerClient<TTaint extends boolean = false>; taintResults: true wraps content/raw in tainted(...) (TaintedA2APeerOutput)
  discover(): Promise<AgentCard>
  map(): Promise<MappedA2APeer<TTaint>>
  registerInto(effectRuntime: EffectRuntime, registry?: PeerAgentRegistrar): Promise<ToolDefinition<A2APeerInput, A2APeerOutputFor<TTaint>>>   // A2APeerInput = { prompt: string; context?: unknown }; A2APeerOutput = { content: string; raw: A2ATaskResult }
}
function createA2AClientEffect(agentUrl: string, options?: { timeoutMs?; headers?; pollIntervalMs? }): A2AEffect<A2ATask, A2ATaskResult>
// ^ a terminal task (completed/failed) in the /tasks/send answer is returned as-is; an { id } answer is polled at /tasks/:id

// Inbound: expose a Loom flow as an A2A agent
function createA2AServer(workflowRuntime: WorkflowRuntime, flow: RegisteredFlow, agentCard: Omit<AgentCard, "url">, options?: A2AServerOptions): A2AServer
// A2AServerOptions: { port?, authenticate?(req): PrincipalChain | undefined, authorize?(req): boolean, insecure?, strict?, ... }
// Refuses to create an unauthenticated server: supply `authenticate` (or `authorize`), or pass `insecure: true` (refused under security.strict).
function toAgentCard(agent: AgentDefinition, opts?: { url?; version?; capabilities? }): AgentCard
class LoomAgentExecutor { constructor(runtime: WorkflowRuntime, flow: RegisteredFlow, options?: { namespaceByPrincipal?: boolean }) /* submit / get / provideInput */ }

interface AgentCard { name: string; description?: string; url: string; version: string; capabilities: { streaming?: boolean }; skills: AgentSkill[]; }
type A2ATaskStatus = "submitted" | "working" | "completed" | "failed";
interface A2ATaskResult { id: string; status: A2ATaskStatus; artifacts?: Array<{ name?: string; parts: A2AMessagePart[] }>; error?: string; }

Browser-safe subpath @loom/plugin-a2a/cards exports only toAgentCard (no node:http).

@loom/capability / @loom/intent / @loom/planner

function capability$<TIn, TOut>(config: {
  id: string; name?: string; intent: string; tags?: string[];
  inputSchema: ZodSchema<TIn>; flow: RegisteredFlow<TIn, TOut>;
  version?: string; dependencies?: string[];       // (2.0) governance metadata
  acceptsTainted?: boolean; aliases?: string[];
}): CapabilityDefinition<TIn, TOut>

class CapabilityCatalog {
  constructor(options?: { authorizer?: CapabilityInvokeAuthorizer })   // (2.0) invoke authorization
  register(capability): this; get(id); all(); ids(); find(query): CapabilityMatch[];
  resolve(id: string): CapabilityDefinition[];       // (2.0) transitive deps, dependency-first; throws on missing/cycle
  registerInto(workflowRuntime: WorkflowRuntime): void;
  invoke<TIn>(workflowRuntime: WorkflowRuntime, capabilityId: string, input: TIn, opts?: { principal?: string }): Promise<ExecutionHandle>;
}
// (2.0) — governs who may INVOKE a capability (distinct from the K2 effect protection ring)
interface CapabilityInvokeAuthorizer { authorize(req: { capabilityId: string; principal?: string; input?: unknown }): { allowed: boolean; reason?: string }; }
class CapabilityInvokeDeniedError extends Error { readonly capabilityId: string; readonly reason: string; }

class IntentRuntime {
  constructor(config: { catalog: CapabilityCatalog; provider?: LlmProvider; reasoning?: ReasoningRuntime; workflowRuntime?: WorkflowRuntime; scheduler?: Scheduler })  // (2.0) reasoning
  route(intentText: string): Promise<IntentRoute | null>    // { capability, confidence, rationale? }
  run(intentText: string, input: unknown): Promise<ExecutionHandle>
}
class PlannerRuntime {
  constructor(config: { catalog: CapabilityCatalog; provider?: LlmProvider; reasoning?: ReasoningRuntime; maxSteps?: number; scheduler?: Scheduler;
                        strict?: boolean })       // (R16) strict: throw PlanFailedError instead of the silent heuristic fallback
  plan(goal: string): Promise<{ goal: string; steps: Array<{ capabilityId: string; rationale? }> }>   // flat, NOT journaled (unchanged)
  planGraph(ctx: Pick<ExecutionContext, "decide">, input: Omit<PlanGoalInput, "catalog">): Promise<PlanGraph>   // (R16) = planGoal over this catalog
}

Both work fully offline (deterministic keyword routing), with an LlmProvider, or — preferred (2.0) — with a ReasoningRuntime (routing/elevation). With a model configured, IntentRuntime.route trusts it: NONE (any case, tolerating quotes and punctuation), an ambiguous reply or one naming no capability id returns null, never a keyword guess (routeExplained() says which); the keyword matcher runs only with no model.

Planning depth (R16, ADR 0123) — plan DAGs, replanning, goals

@loom/planner also exports a journaled, validated, executable plan. Kernel untouched; everything is ctx.decide, ctx.parallel and ctx.execute.

interface PlanNode { id: string; capabilityId: string; args?: { [k: string]: Json };   // args may hold { "$from": "<stepId>[.path]" }
                     dependsOn?: string[]; preconditions?: { ref: string; test?: "exists" | "truthy" | "equals"; value?: scalar }[]; rationale?: string }
interface PlanGraph { goal: string; nodes: PlanNode[] }
function validatePlanGraph(plan: unknown, opts: { catalog; limits?: Partial<PlanLimits>; external?: ReadonlySet<string>; reserved?: ReadonlySet<string> }):
  { ok: true; order: string[] } | { ok: false; issues: PlanIssue[]; truncated: boolean }        // cycles, unknown caps, dangling refs, schema-invalid args, bounds
function assertValidPlanGraph(plan, opts): string[]                                           // throws PlanValidationError
function resolveRef(ref: string, outputs: ReadonlyMap<string, unknown>): unknown; function resolveArgs(args, outputs): Record<string, unknown>
function checkPrecondition(p: PlanPrecondition, outputs): string | undefined

// Journaled planning: the model runs INSIDE ctx.decide (`<planId>:plan`); replay makes no model call. No silent fallback.
function planGoal(ctx: Pick<ExecutionContext, "decide">, input: { goal: string | TaintedValue<string>; catalog: CapabilityCatalog; model: PlanModel;
                  planId?: string; maxAttempts?: number /* 3 */; limits?: Partial<PlanLimits>; fallback?: (goal: string) => PlanGraph }): Promise<PlanGraph>   // throws PlanFailedError
type PlanModel = (scope: DecideScope, request: LlmRequestInput) => Promise<LlmResponse>
function effectPlanModel(toolId: string): PlanModel; function providerPlanModel(provider: LlmProvider): PlanModel; function heuristicFallback(catalog, maxSteps?): (goal) => PlanGraph

// The executor: waves of ready nodes through ctx.parallel (Kahn, O(V+E)); outputs bound into dependents; failures typed.
function executePlan(ctx: ExecutionContext, plan: PlanGraph, config: {
  catalog: CapabilityCatalog; planId?: string; limit?: number /* 4 */; onFailure?: "fail-fast" | "continue-independent";
  invoke?: (call: { node; args; stepId; ctx: BranchContext }) => Promise<unknown>;       // must be a durable frame; default: tool task toolId = capabilityId
  replan?: { max: number; revise: (ctx, req: ReplanRequest) => Promise<PlanGraph>; maxTotalNodes?: number };
  reflect?: (ctx, wave: { round; completed: string[]; failed: string[] }) => Promise<"continue" | "replan" | "stop" | undefined>;
}): Promise<{ status: "completed" | "failed" | "stopped"; outputs; failures: Record<string, StepFailure>; skipped: string[]; replans: number; nodes: PlanNode[]; reason?: string }>
function makeReviser(cfg: { model: PlanModel; maxAttempts?; maxReplyChars? }): (ctx, req) => Promise<PlanGraph>   // decision `<req.planId>:replan:<n>`; catalog + limits come from the request; failure context tainted
class IdReusedError extends Error   // a planId (planGoal / executePlan) or goalsClient prefix used twice in one execution is refused
// capability$({ ..., acceptsTainted: true }) lets a plan bind a tainted value (schema checked on the payload; the tool gets the wrapper); default: refused

// Goals: principal-scoped, durable, a status machine. The owner is NEVER an input.
interface Goal { id; owner; parentId?; title; status: "open" | "active" | "done" | "failed" | "abandoned"; revision; seq; note?; holder?; lastRun? }
interface GoalStore { create(owner, { id, title, parentId? }, opts?: { idempotencyKey? }); get(owner, id); list(owner, filter?); setStatus(owner, { id, status, note?, expectedRevision?, lastRun?, holder? }, opts?); rollup(owner, id, opts?) }
class InMemoryGoalStore implements GoalStore; class DurableGoalStore implements GoalStore { constructor(kv: GoalKvStore /* DurableStateStore satisfies it */) }
function registerGoalEffects(registry, store): void; function goalsClient(ctx, { prefix? }): { create; get; list; setStatus; rollup }     // owner = ctx principal
function runGoal(ctx: ExecutionContext, goals: GoalsClient /* from goalsClient(ctx), not a GoalStore */, goalId: string, config: { catalog; plan: { model, ... }; execute?; isExecutionLive? }): Promise<RunGoalResult>   // RunGoalResult = { run?: PlanRun; goal: Goal }

A failed step is recorded as a decision (<step>:outcome), so a resume serves it instead of re-running the capability; a replan replaces every node that has not completed, may reference completed outputs, and may not reuse any earlier id.

@loom/conversation — a durable dialogue as one chained execution

A conversation is ONE chained execution (ADR 0109): user turns arrive on the inbox (ADR 0110), each is answered by agentLoop$, every message is recorded as a decision, and a long dialogue rotates by continueAsNew. Needs kernel.features.inbox and kernel.features.continueAsNew. Kernel untouched (ADR 0122).

// The flow: register it, then drive it with createConversations.
function conversation$(config: ConversationConfig, options?: { id?: string; name?: string; version?: string }): ConversationFlow
   // ConversationFlow = RegisteredFlow<ConversationCarry | Record<string, never>, ConversationResult> & { mode, config, sharedTurn? }
function conversationRunner(config: ConversationConfig, shared?: { turnEffectId: string }): FlowRunner<ConversationCarry | Record<string, never>, ConversationResult>   // the body, for your own flow
function summarizerText(content: unknown): string   // reads a summarizer/compaction message: unwraps tainted() at every level (top, text parts, tool_result content); result is UNTRUSTED data; re-wrap with tainted() before any LLM call; content nested > 16 levels becomes SUMMARIZER_TEXT_TRUNCATED (exported), never silently dropped

// F4: build a host-signed inbox envelope (tests / trusted bridges). Needs the conversation's `key`; authorizes nothing by itself
// (participant, replay, staleness and the runtime access policy all still apply). The same signer sendTurn uses.
function signInboxEnvelope(key: string, body: InboxEnvelopeBody): Record<string, unknown>
interface InboxEnvelopeBody { conversationId: string; kind: "user" | "close"; id: string; iat: number; text?: string; asker?: Identity; editOf?: number }

interface ConversationConfig {
  key: string;                                       // HMAC secret: every inbox envelope is host-signed, both modes
  previousKeys?: readonly string[]; maxEnvelopeAgeMs?: number; maxInvalidRecords?: number;
  shared?: SharedConfig; onError?: (errorId: string, err: unknown, info: { conversationId: string; turn: number }) => void;
  agent: Omit<AgentLoopConfig, "signal" | "loopId" | "requireApproval" | "needsApproval">;   // any agentLoop$ config
  budget?: {
    contextWindow: number;                 // tokens
    reserveOutputTokens?: number;          // default min(4096, window/4)
    counter?: TokenCounter;                // default heuristicTokenCounter()
    summarizerToolId?: string;             // { messages } -> { summary }: the same contract as agentLoop$ compaction.toolId; both may carry tainted() wrappers: read messages with summarizerText
  };
  bargeIn?: BargeInSource;                 // inboxBargeIn(eventStore); absent = the conversation QUEUES
  hub?: ConversationHub;
  rotateEveryTurns?: number;               // default 100
  maxTurnChars?: number;                   // default 32_000 (longer is recorded as rejected)
  maxReplyChars?: number;                  // default 64_000
  maxWindowTurns?: number;                 // default 64
  maxCarryBytes?: number;                  // default 256 KiB
}

// The client. Every method is an existing authorized runtime call.
function createConversations(deps: { runtime: WorkflowRuntime; eventStore: EventStore; flow: ConversationFlow /* what conversation$ returns */; maxTurnChars?: number;
  effects?: EffectRuntime; bargeIn?: BargeInSource; principalFor?: (asker: Identity) => PrincipalChain | Promise<PrincipalChain>; policy?; runtimePolicyIsOwnerScoped?: boolean; maxQueuedPerAsker?: number; now?: () => number }): Conversations
interface Conversations {
  start(o?: { principal?; conversationId? }): Promise<{ conversationId: string }>;        // runtime.start (owner = caller)
  sendTurn(id, text, o?: { principal?; messageId?; editOf? }): Promise<SendResult>;               // runtime.send; a retry with the same messageId is delivered once
  close(id, o?): Promise<SendResult>;
  history(id, o?: ReadOptions & { principal? }): Promise<ReadResult>;                    // bounded; both directions; cursor
  status(id, o?): Promise<{ conversationId; executionId; generation; state: "open"|"closed"|"failed"|"cancelled"; running: boolean; turns: number }>;
  authorizeRead(id, o?): Promise<{ generation: number }>;                                 // the gate a transport runs once per connection
  readonly reader: ConversationReader;
  readonly mode: "owner" | "shared";
  readonly requiresPrincipal: boolean;
  // Owner-only by default: any caller who is not the owner (tenant+actor+onBehalfOf) gets ConversationNotFoundError.
  // SHARED mode (conversation$({ shared })): participants + per-viewer filtering:
  participants(id, o?): Promise<Identity[]>;                 // owner / manager
  addParticipant(id, who: Identity, o?): Promise<void>;
  removeParticipant(id, who: Identity, o?): Promise<void>;   // the owner cannot be removed
  filterEntries(entries: HistoryEntry[], principal?): Promise<HistoryEntry[]>;   // restricted -> bare placeholder (status "restricted")
  viewerKey(principal?): string | undefined;
  isParticipant(id, principal?): Promise<boolean>; isOwner(id, principal?): Promise<boolean>;
  reasoningTarget(id, cursor: string, o?): Promise<{ executionId: string; stepPrefix?: string }>;
}

interface SharedConfig {
  servicePrincipal: PrincipalChain;                    // OWNS the coordinating execution; no end-user identity may equal it
  memoryScope?(asker: Identity): string;               // required if agent.memory is set; a constant agent.memoryScope is refused
  directory: ParticipantDirectory;                     // logParticipants(eventStore)
  cleared(viewer: Identity, classes: readonly string[]): boolean | Promise<boolean>;   // REQUIRED; fail closed
  classOf?(toolName: string): string | undefined;     // undefined -> the label "unclassified"
  manageParticipants?(manager: Identity, conversationId: string): boolean | Promise<boolean>;
  turnEffectId?: string;                               // default "conversation.turn": grant it to the OWNER
}
interface Identity { tenant?: string; actor: string; onBehalfOf?: string }

// What history returns: one entry per message, with the cursor to resume from.
interface HistoryEntry { turn: number /* 0 = a rejected record, no turn */; ord?: number /* unique per record; absent before R17b */; editOf?: number /* ord of the user turn this edits */; editOfTurn?: number /* its turn number, display */; role: "user"|"assistant"; text: string;
  status: "complete"|"interrupted"|"failed"|"rejected"; reason?: string;
  cursor: string /* "<generation>.<sequence>" */; executionId: string; at?: number }
interface ReadOptions { limit?: number /* 50, max 500 */; maxScan?: number /* 2000 */; cursor?: string; direction?: "forward"|"backward" }
interface ReadResult { entries: HistoryEntry[]; nextCursor?: string; done: boolean; closed?: boolean }

// Following the log: bounded reads, the store's subscribe only wakes it.
function followConversation(deps: { reader; eventStore }, id: string, o?: { cursor?; signal?; pollMs?; pageLimit?; maxScan? }):
  AsyncGenerator<{ type: "message"; entry: HistoryEntry } | { type: "closed" }>
function createConversationReader(store: EventStore): ConversationReader      // NO authorization: authorize before calling
function findHeadGeneration(store, conversationId, hint?): Promise<number | undefined>   // O(log G)

// After a turn: one call per settled turn, once its closing record is durable. Pass it as the runtime's
// onDurableEvent (ADR 0147): never on replay or recovery, at most once (missed if the process dies first).
function turnSettledObserver(flow: { definition: { id: string } }, listener: (e: TurnSettledEvent) => void | Promise<void>):
  (event: EventEnvelope) => void | Promise<void>
interface TurnSettledEvent { conversationId: string; executionId: string; turn: number /* 0 = rejected */; ord: number;
  status: "complete" | "interrupted" | "failed" | "rejected"; asker?: Identity }   // ids and status only, never text

// Barge-in: the signal comes from outside the body, and is read only through agentLoop$'s recorded checkpoints.
type BargeInSource = (conversationId, ownerId, h: { onMessage(): void; isKnown(id: string): boolean }) => Promise<() => void> | (() => void)
function inboxBargeIn(eventStore: EventStore): BargeInSource

// Token budgeting: a pure fit; conversation$ records the verdict as a decision.
interface TokenCounter { count(text: string): number | Promise<number>; readonly perMessage?: number }
function heuristicTokenCounter(o?: { asciiCharsPerToken?: number }): TokenCounter     // ASCII 1/4 token, other code points 1 token
function fitToWindow(req: BudgetRequest): Promise<{ ok: true; keep: number; counted: [number, number][]; tokens: number }
                                                | { ok: false; reason: "prompt-too-large"; tokens: number; available: number }>

// The live (transient) channel.
class ConversationHub {
  constructor(o?: { maxSubscribersPerConversation?: number /* 64 */; maxPartialChars?: number /* 65_536 */ });
  wrapProvider(provider: LlmProvider): LlmProvider;                  // a barge-in cuts the generation; the partial survives
  deltaSink(): (delta: string) => void;                              // createAgentModelEffect(..., { stream: true, onDelta })
  reasoningSink(): (delta: TaintedValue<string>) => void;            // createAgentModelEffect(..., { reasoning: { store, live: true, onDelta } }) — ADR 0133 D6
  subscribe(conversationId, listener: (e: LiveEvent) => void): () => void;   // throws SubscriberLimitError
  partialOf(conversationId): { turn: number; text: string } | undefined;
  reasoningPartialOf(conversationId): { turn: number; text: string; asker?: string } | undefined;   // bounded like the reply; cleared with it
}
type LiveEvent = { type: "delta"; conversationId; turn; text } | { type: "reasoning"; conversationId; turn; text; asker? } | { type: "reset"; conversationId; turn }

ConversationConfig REQUIRES key (the HMAC secret every inbox envelope is signed with, both modes) and takes maxEnvelopeAgeMs? (1 h) and maxInvalidRecords? (50). createConversations REFUSES a runtime whose policy is not owner-scoped for query / send / cancel / resume / actAsOwner: pass policy (the executionAccessPolicy(...) object; it exposes read-only scopes) or runtimePolicyIsOwnerScoped: true; it also takes principalFor, maxQueuedPerAsker (8) and, for a shared conversation, effects. ConversationConfig also takes shared?: SharedConfig and onError?(errorId, err, { conversationId, turn }) (the host’s log of the real error behind a failed turn). BudgetConfig takes maxSummaryTokens? (default a quarter of the window).

Errors: ConversationNotFoundError, InvalidTurnError, InvalidCursorError, SubscriberLimitError; authorization failures are the runtime’s own (ExecutionDeniedError). Message statuses: a user turn is complete or rejected (malformed, too-long, prompt-too-large); a reply is complete, interrupted (the partial, possibly empty) or failed. User text is untrusted: render it as text.

@loom/plugin-conversation-sse — the end-user gateway

function createConversationSseServer(o: {
  conversations: Conversations; hub: ConversationHub; eventStore: EventStore;
  authenticate?: (req) => PrincipalChain | undefined | Promise<...>;   // required unless insecure; the principal needs an `actor`
  insecure?: boolean; strict?: boolean; warn?: (m: string) => void;
  port?: number; host?: string /* "127.0.0.1" */; basePath?: string /* "/conversations" */;
  allowedOrigins?: readonly string[];
  maxConnections?: number /* 256 */; maxConnectionsPerPrincipal?: number /* 8 */;
  maxPendingBytes?: number /* 1 MiB */; maxBodyBytes?: number /* 64 KiB */;
  heartbeatMs?: number /* 15_000 */; maxStreamMs?: number /* 15 min */; reauthMs?: number /* 30_000 */; pollMs?: number /* 2_000 */;
  onError?: (errorId: string, err: unknown) => void;
  reasoning?: boolean | { live?: boolean; store?: ReasoningReader };   // ADR 0133 D6, OFF by default: `reasoning` / `reasoning-partial` events; { store } adds GET …/reasoning?cursor=
  // edge hardening (ADR 0122 D6): strict defaults from the runtime; allowNoRuntimeAuthz?: boolean (refused by default);
  // allowedHosts?: string[] (insecure servers: loopback only); allowCredentials?: boolean (CORS, listed origins only);
  // rateLimit?: { max?: number /* 120 */; windowMs?: number /* 60_000 */ } (POSTs, per principal); now?: () => number;
  // requestTimeoutMs?: number /* 30_000 */; headersTimeoutMs?: number /* 15_000 */; maxSockets?: number /* 4096 */;
}): { start(): Promise<void>; stop(): Promise<void>; readonly url: string; readonly connections: number; readonly http: http.Server }

Shared mode adds GET /conversations/:id/participants, POST …/participants and POST …/participants/remove. Routes: POST /conversations (the server mints the id), GET /conversations/:id, POST /conversations/:id/turns ({ text, messageId?, editOf? }), POST /conversations/:id/close, GET /conversations/:id/history?limit&cursor&direction, GET /conversations/:id/events (SSE; Last-Event-ID or ?cursor). SSE events: status, partial, message (SSE id = the history cursor), delta, reset, closed, reconnect, error; with the opt-in reasoning option also reasoning and reasoning-partial, and GET /conversations/:id/reasoning?cursor=<the reply's history cursor> (needs { store }). Reasoning goes to the turn’s asker only: in shared mode never to another participant, the owner included. Every refusal for a conversation the caller may not touch is the same 404. See ADR 0122 D6 for the security model.

@loom/memory — agent memory

class MemoryRuntime {
  constructor(store?: MemoryStore)                 // default InMemoryMemoryStore
  register(definition: MemoryDefinition): this      // enforces ttlMs / maxEntries
  append(memoryId: string, scope: string, entry: { role?: string; content: unknown; timestamp?: number }): Promise<void>
  read(memoryId: string, scope: string): Promise<MemoryEntry[]>
  clear(memoryId: string, scope: string): Promise<void>
}
interface MemoryStore {
  append(key: string, entry: MemoryEntry): Promise<void>   // a key names an ORDERED LIST
  read(key: string): Promise<MemoryEntry[]>                // in APPEND order; absent key => []
  clear(key: string): Promise<void>                        // idempotent
  replace?(key: string, entries: readonly MemoryEntry[]): Promise<void>  // OPTIONAL: atomic rewrite
  search?(key: string, query: string, opts?: { limit?: number }): Promise<MemoryEntry[]>  // OPTIONAL: by relevance
  close?(): Promise<void>                                  // OPTIONAL: release resources
}
// what a backend indexes, and what a term is — exported because both are contract
function searchableText(entry: MemoryEntry): string        // string/number leaves of `content`; iterative, depth-capped
function tokenize(text: string): string[]                  // lowercase, non-alphanumeric split
// SPI 2.1.0 — a term-based backend derives QUERY terms from this: deduplicated and capped, because
// OR-ing one word 20,000 times cannot change which rows match and cost SQLite 32.7s to say so
function queryTerms(query: string): string[]
const MAX_QUERY_TERMS = 256
// a backend's `search` MUST route its limit through this — raw, a negative limit means NO limit on some backends
function clampSearchLimit(limit: number | undefined): number
const DEFAULT_SEARCH_LIMIT = 20
const MAX_SEARCH_LIMIT = 10_000                            // mirrors read-model's MAX_QUERY_LIMIT
const MEMORY_STORE_SPI_VERSION: string                     // pin a third-party backend against this
class InMemoryMemoryStore implements MemoryStore {}        // default; lost on restart
// durable: SqliteMemoryStore (@loom/plugin-sqlite), PostgresMemoryStore (@loom/plugin-postgres)
// third-party: loom-plugin-vector (cosine similarity over a pluggable Embedder)

Embeddings (R18, ADR 0124; SPI 1.0.0).

interface EmbeddingProvider { readonly modelId: string; readonly dimensions: number; readonly maxBatchSize?: number;
  embed(texts: readonly string[], opts?: { signal?: AbortSignal }): Promise<number[][]> }
interface VectorIndex { modelId?: string; dimensions: number; add(key, id, vector); remove(key, ids); clear(key);
  search(key, query, limit, minScore): { id, score }[]; size(key) }
class InMemoryVectorIndex   // exact cosine, bounded top-k
class HashingEmbeddingProvider   // deterministic, lexical; the offline reference
class EmbeddingMemoryStore implements MemoryStore { constructor(o: { store; provider; minScore /* required floor */; index?; maxEntriesPerKey?; batchSize? }) }
// kits (@loom/conformance): runEmbeddingProviderConformance, runVectorIndexConformance

Tiered memory (2.0). Named tiers over the scoped store, each with an eviction or summarization policy, plus promotion. Outcome-affecting recall can be logged (replay-safe).

interface TierPolicy { ttlMs?: number; maxEntries?: number; summarizeAfter?: number; }   // eviction OR summarization
type Summarizer = (entries: MemoryEntry[]) => Promise<unknown> | unknown;
const DEFAULT_TIERS: Record<string, TierPolicy>;   // working / episodic / semantic / procedural

class TieredMemory {
  constructor(config: { tiers: Record<string, TierPolicy>; store?: MemoryStore; summarizer?: Summarizer; now?: () => number })
  remember(tier: string, scope: string, entry: { role?; content; timestamp? }): Promise<void>   // auto-summarizes bounded tiers
  recall(tier: string, scope: string, opts?: { limit?: number; decide?: DecideFn; decisionKey?: string }): Promise<MemoryEntry[]>  // the tail; pass ctx.decide ⇒ replay-safe
  recallRelevant(tier: string, scope: string, query: string, opts: { decide: DecideFn; limit?: number; decisionKey?: string }): Promise<MemoryEntry[]>  // by relevance; `decide` REQUIRED, needs a store with search()
  promote(from: string, to: string, scope: string, opts?: { filter?: (e: MemoryEntry) => boolean; move?: boolean }): Promise<number>
  summarize(tier: string, scope: string, opts?: { keep?: number }): Promise<boolean>
  clear(tier: string, scope: string): Promise<void>
}

@loom/knowledge — a corpus over the memory seam

There is no knowledge$ construct and no embedding SPI (ADR 0090). This package owns exactly one thing the store cannot: the mapping from a DOCUMENT — the unit a corpus is edited in — to the PASSAGES it is searched in, so a revision supersedes its predecessor instead of being indexed twice (ADR 0091).

function openKnowledgeBase(store: MemoryStore, options: Omit<KnowledgeBaseConfig, "memory">): KnowledgeBase   // builds the TieredMemory over `store`; options: scope, tier?, chunker?, ...
class KnowledgeBase {
  putDoc(doc: KnowledgeDoc, opts?: SourceOptions): Promise<Passage[]>    // UPSERT, not append
  sync(docs: readonly KnowledgeDoc[], opts?: SourceOptions): Promise<SyncResult>  // write/remove a set
  planSync(docs: readonly KnowledgeDoc[], opts?: SourceOptions): Promise<SyncResult>  // dry run
  removeDoc(docId: string): Promise<void>
  listDocs(): Promise<DocRecord[]>
  retrieve(query: string, opts: RetrieveOptions): Promise<Passage[]>     // REQUIRES ctx.decide
}
function packParagraphs(maxChars?: number): Chunker      // the default; pass your own for structure
// the prompt boundary and the one hallucination that is DECIDABLE rather than judged
function groundedRequest(opts: GroundedRequestOptions): LlmRequestInput       // passages in a USER turn
function checkCitations(claimed: readonly string[], passages: readonly Passage[]): CitationCheck
function assertGrounded(claimed: readonly string[], passages: readonly Passage[]): string[]  // throws

Reached through app.knowledge(scope), over the same memoryStore that backs app.memory; operated from the CLI with loom knowledge sync|search|list.

@loom/sandbox — the sandbox backend SPI

The engine seam for running model-written code in your own isolation boundary (ADR 0144). SANDBOX_BACKEND_SPI_VERSION is 1.0.0. @loom/plugin-sandbox (below) is the QuickJS reference backend; every other backend is a third-party package.

interface SandboxBackend { capabilities: SandboxBackendCapabilities; evaluate(r: SandboxRequest): Promise<SandboxOutcome>; warm?(): Promise<void>; close?(): Promise<void> }
interface SandboxRequest { script: string; inputJson: string; limits: { wallClockMs: number; memoryBytes: number }; signal?: AbortSignal }
type SandboxOutcome =
  | { ok: true; value: unknown }
  | { ok: false; kind: "limit"; limit: "cpu" | "memory" | "wallClock" }
  | { ok: false; kind: "threw"; name?: string; message: string }
  | { ok: false; kind: "inputTooLarge" }
  | { ok: false; kind: "infrastructure"; message: string }
interface SandboxBackendCapabilities { language: string; network: "none" | "allowlist" | "open"; wallClock: "hard" | "soft"; memory: boolean; cpu: boolean; freshPerRun: boolean }
class SandboxEffect implements Effect { constructor(scripts: ScriptReader, backend: SandboxBackend, options?: SandboxEffectOptions); idempotent: boolean /* network === "none" */; warm(); close() }
interface SandboxEffectOptions { limits?: { memoryBytes?; wallClockMs?; maxOutputBytes?; maxInputBytes? }; allowNetwork?: boolean; allowReusedEnvironment?: boolean; backendGraceMs?: number }
function registerSandboxBackend(effectRuntime: EffectRuntime, scripts: ScriptReader, backend: SandboxBackend, spec?: { id?: string } & SandboxEffectOptions): SandboxEffect
// moved here from @loom/plugin-sandbox (re-exported there): sandboxEval$, SANDBOX_TOOL_ID, SANDBOX_ERRORS, SandboxEvalInput, ScriptReader, shapeHash, shapeOf, boundText

The effect validates every outcome at run time (a malformed one fails untyped), bounds threw text itself, lets cancellation win over any outcome, maps infrastructure to an untyped failure, and returns the success value as its JSON round-trip, so the first run and a replay see the same value. runSandboxBackendConformance (@loom/conformance) proves a backend; loom plugin new sandbox <name> scaffolds one.

@loom/plugin-sandbox — untrusted code as an effect

function sandboxEval$(id: string, toolId?: string /* default SANDBOX_TOOL_ID */): StepDefinition<SandboxEvalInputValue, unknown>
function registerSandboxEval(effectRuntime: EffectRuntime, scripts: ScriptReader, spec?: SandboxSpec): SandboxEvalEffect
interface SandboxSpec { id?: string; limits?: SandboxLimits; workers?: SandboxWorkers }
interface SandboxWorkers { count?: number;
                           hardKillGraceMs?: number;    // past wallClockMs before a hard kill; default 2000
                           startupLivenessMs?: number;  // ready worker hasn't STARTED the script within wallClockMs + grace + this → error; default 10000
                           coldStartMs?: number }       // cold spawn + WASM compile allowance, independent of run limits; default 10000
                                                        // hardKillGraceMs/startupLivenessMs: finite and >= 0; coldStartMs: finite and > 0
                                                        // (0 would fail every cold start forever — it is the only clock a compile is judged against)
// step input: { scriptRef: ContentRef; input?: unknown; expectedOutputShapeHash?: string }
const SANDBOX_ERRORS: {             // typed failure channels (ADR 0060) — zod schemas
  scriptMissing: { ref: string };
  limitExceeded: { kind: "cpu" | "memory" | "wallClock" | "output" };   // the SCRIPT's own outcome — routes to drift-repair
  shapeMismatch: { expected: string; observed: string };
  scriptError: { name?: string; message: string };   // the script's own throw; ≤128 / ≤2048 chars, no stack
  inputTooLarge: { bytes: number; limit: number };        // caller's `input` — NOT limitExceeded, a script rewrite can't shrink it
  inputNotSerializable: { reason: string };                // `input` can't become JSON (BigInt/cyclic/undefined-at-top-level)
}
interface SandboxLimits { memoryBytes?: number; interruptBudget?: number; wallClockMs?: number;
                          maxOutputBytes?: number;    // EMISSION is capped too — output lands in the log; default 1 MiB
                          maxInputBytes?: number }    // CONSUMPTION cap, checked once before either engine runs; default = memoryBytes's default (32 MiB, ADR 0089 D4)
function shapeOf(value: unknown): string; function shapeHash(value: unknown): string
class SandboxWorkerPool {   // opt-in (ADR 0089): a hard kill for a script that never yields
  runJson(script: string, inputJson: string, limits: EvalLimits, signal?: AbortSignal): Promise<EvalOutcome>   // `inputJson` is PRE-serialized (not the raw input)
}
class SandboxWorkerStartupError extends Error { waitedMs: number; limitMs: number; phase: "compile" | "start" }  // not a TypedFailure; retryable by default
// a `scriptRef` is re-hashed before eval — ContentStore.get carries no verify-on-read obligation
class QuickJsSandboxBackend implements SandboxBackend { constructor(o?: { interruptBudget?: number; workers?: SandboxWorkers }) } // ADR 0144: the engine; SandboxEvalEffect is SandboxEffect over it

@loom/plugin-sentinel — the perpetual-agent preset

function sentinelPreset<TState>(config: {
  id: string; cron: string; timeZone?: string;  // schedule id (stable identity), and when it wakes
  flowId?: string;                              // default `<id>-flow`
  state: { schema: ZodSchema<TState>; version?: number; migrations? };   // carry-state contract
  stateStore: DurableStateStore;                // carry state ACROSS wakes (ADR 0053)
  contentStore: ClaimCheckStore;                // over-cap observations go by reference
  inlineCapBytes?; digests?; onOffload?; pinnedEventTypes?;
}): SentinelPreset<TState>   // { schedule, state, middleware, pinnedEventTypes, register(effects), pinPredicate, occurrenceId, occurrenceOf }

@loom/learning — off-policy evaluation over the decision log

function decideWithPolicy<A>(ctx: DecideCapable, decisionId: string, policy: Policy<A>, context: PolicyContext, actions: readonly A[], opts?: { rationale?; source?; refs? }): Promise<A>
// records context + policy.propensity at DECISION time — a propensity cannot be reconstructed later
function trajectories<A = unknown>(events: readonly EventEnvelope[], opts?: TrajectoryOptions): Trajectory<A>[]
function evaluatePolicy<A>(trajectories: readonly Trajectory<A>[], policy: Policy<A>, opts?: EvaluateOptions): EvalReport
// SNIPS with clipping + effective sample size; refuses a value estimate without propensities
function promotionGate(report: EvalReport, thresholds?: PromotionThresholds): PromotionVerdict
function policyManifest(report: EvalReport, verdict: PromotionVerdict, opts?: { promotedAt?: number; canary?: number }): PolicyManifest

The learning lifecycle (R18, ADR 0124).

// evaluation — SNIPS unchanged; opt-in doubly-robust and seeded cluster-bootstrap intervals
evaluatePolicy(trajs, candidate, { doublyRobust: true | { rewardModel }, bootstrap: { resamples?, level?, seed? } }): EvalReport
//   report.doublyRobust?: { value, lift, steps, rewardModel }   report.intervals?: { snips?, dr? }  (EstimateInterval: value/lift {lower, upper})
promotionGate(report, { minLiftLowerBound?: number, intervalEstimator?: "snips" | "dr", minClusters? /* default 20 */ })   // no interval, or too few clusters, FAILS this

// registry — CAS store (DurableStateStore satisfies PolicyKvStore), fixed to one authenticated scope
class PolicyRegistry { constructor(o: { kv: PolicyKvStore; scope: string; thresholds?: PromotionThresholds });
  register({ id, version, contentHash, now }); bootstrap({ id, version, now });
  promote({ id, version, report, thresholds?, mode: "full" | "canary" | "shadow", canaryFraction?, now });  // the registry runs promotionGate itself; a supplied verdict is refused
  ramp({ id, canaryFraction, now }); rollback({ id, to?, now, note? });  // `to` must be a version that was once the incumbent
  resolve(id): RolloutState | undefined; version(id, v); versions(id); history(id) }
class InMemoryPolicyKv implements PolicyKvStore   // a CAS store in memory with DurableStateStore's semantics — tests and single-process use
// Registry errors, all extending PolicyRegistryError: PolicyAccessDeniedError (the stored document belongs to another scope), PolicyVersionConflictError (a version re-registered with different content),
//   PromotionRefusedError (reasons: readonly string[]), PolicyNotFoundError, PolicyConflictError
decideWithRollout(ctx, decisionId, { source, policyId, resolve }, unitKey, context, actions, { refs?, bucketSecret? }): Promise<A>
//   records policy: { id, version, propensity?, refs?, rollout: { registry, mode, bucket?, armProbability?, acted, shadow? } }
rolloutBucket(salt, unitKey, secret?): number  // [0,1)

// versions
promptVersion(id, template): VersionRef; modelVersion(id, config): VersionRef; contentHash(value); sliceByVersion(trajs, "prompt" | "model" | "policy")

// export — async generator of canonical JSONL lines; tenant required; list/load supplied by the app
exportJsonl({ source: ExportSource, tenant, principal?, kinds?, taint?: "exclude" | "mark", cursor?, pageSize?, maxExecutions?, maxRecords?, ... }, stats?): AsyncGenerator<string>

@loom/orchestrator — composable orchestration

function decideApproval(
  input: { goal: string; riskLevel: string },        // riskLevel e.g. "low" | "medium" | "high"
  handlers: { onAutoApprove?: () => void; onManualRequired?: () => void; waitForManual: () => Promise<ApprovalDecision> }
): Promise<ApprovalDecision>                          // auto-approves when riskLevel==="low" + goal matches the policy
function autoApprovalAllowed(goal: string): boolean

function dispatchPlan<TStep>(config: {
  goal: string;
  route?: (goal: string) => Promise<unknown>;
  plan: (goal: string) => Promise<{ steps: TStep[] }>;
  dispatch: (step: TStep, index: number) => Promise<{ status: string; artifact: unknown }>;
  onRouted?; onPlanned?; onResult?; isTerminal?: () => boolean;
}): Promise<TStep[]>

Compute & distribution

The same flows run in-process, over HTTP, or on Cloud Run — the ExecutionPackage carries resume coordinates, never code. Swapping placement is a constructor change.

@loom/worker-runtime — the SPI

interface WorkerRuntime {
  allocate(request: WorkerRequest): Promise<WorkerLease>;
  execute(lease: WorkerLease, pkg: ExecutionPackage): Promise<ExecutionResult>;
  release(lease: WorkerLease): Promise<void>;
}
interface ExecutionPackage {
  executionId: string; workflowId: string; flowId: string;
  input?: unknown; resumeEvent?: { eventType: string; payload: unknown; waitId?: string };
  snapshotId?: string; fromSequence?: number; metadata?: Record<string, unknown>;
  principal?: Record<string, unknown>;  // (1.2.0, R3) the authenticated caller; a strict worker authorizes the dispatch as it
}
type ExecutionOutcome = "completed" | "suspended" | "failed";
interface ExecutionResult { executionId: string; status: ExecutionOutcome; state: ExecutionState; output?: unknown; error?: unknown; suspendedOn?: SuspendCondition; lastSequence: number; }
interface WorkerRequest { executionId: string; metadata?: Record<string, unknown>; principal?: Record<string, unknown>; }
interface WorkerLease   {
  leaseId: string;
  fence?: number;                 // (2.0, K9.4) monotonic fencing token acquired at allocate time
  metadata?: Record<string, unknown>;
}
// (L20) "another holder owns this execution" — a settled outcome, never a failed attempt
function isSupersededRefusal(err: unknown, executionId: string): boolean;
class ExecutionSupersededError extends Error { readonly executionId: string; readonly cause?: unknown; }  // the wire form

Superseded is not failed (L20, ADR 0004 amendment). isSupersededRefusal is true for an ExecutionAlreadyExistsError, a StaleFenceError (bare or as a StorageWriteError’s cause) or an ExecutionSupersededError — each only when it names THIS execution. A dispatcher MUST NOT retry it: every attempt fences, so the retry would fence the winner out and the two would ping-pong. It transfers ownership to the newer fence holder without checking that holder is alive, so a deployment MUST run recoverAbandoned to re-drive dead holders; a caller that needs the outcome awaits the execution by id. A start refused on the fence with sequence 0 still absent is StartFencedOutError (@loom/workflow-runtime) instead — its holder wrote nothing and may be dead, so it stays retryable.

Implementations: new LocalWorkerRuntime(runtime: WorkflowRuntime, { system? }) (in-process; no system grant unless system is passed), new RemoteWorkerRuntime(baseUrl: string, { fetchImpl? }) (HTTP → POST {baseUrl}/execute).

Fencing (2.0, K9.4). On a fencing-capable store, every event a run appends carries its lease.fence; once a re-dispatch acquires a newer token, the older lease’s holder can no longer write — a partitioned “zombie” worker is fenced out at the store rather than double-writing. Absent on single-node SQLite (correlation only).

@loom/worker-runtime-local — the in-process worker

class LocalWorkerRuntime implements WorkerRuntime {
  // runs an ExecutionPackage in THIS process; acquires the K9.4 fence automatically, so
  // exactly one version of an execution's history can land even under recovery
}

@loom/dispatcher — placement

interface ExecutionDispatcher {
  dispatch(pkg: ExecutionPackage, meta?: DispatchMetadata): Promise<ExecutionResult>;  // (1.1.0, L20) MUST NOT retry an isSupersededRefusal — rethrows it
  cancel(executionId: string): Promise<void>;
  health(): Promise<DispatcherHealth>;
}
const DISPATCHER_SPI_VERSION = "1.2.0";
interface DispatchMetadata { lane?: string; priority?: number; estimatedCpu?: number; estimatedMemory?: number; estimatedDuration?: number; target?: string; }  // (2.0) target
class LocalDispatcher { constructor(worker: WorkerRuntime, options?: { maxAttempts?: number; executeTimeoutMs?: number }) }

Policy-driven placement (2.0, M4). Route each dispatch to a named target dispatcher by an ExecutionPolicy.

type PlacementPolicy = (pkg: ExecutionPackage, meta: DispatchMetadata | undefined, targets: readonly string[]) => string;
function dispatchMetadataFromPolicy(policy: ExecutionPolicy): DispatchMetadata;   // computeTarget→target, priority, resources→estimated*, affinity→lane
function defaultPlacementPolicy(defaultTarget: string): PlacementPolicy;
class PlacementDispatcher implements ExecutionDispatcher {
  constructor(options: { targets: Record<string, ExecutionDispatcher>; default: string; policy?: PlacementPolicy; onWarn?: (message: string) => void })
}

Fault supervision (2.0, K4). Restart/backoff + a per-key circuit breaker around any child dispatcher.

interface SupervisionPolicy { maxRestarts?: number; backoffMs?: number; backoffFactor?: number; circuit?: { threshold: number; cooldownMs: number; key?: (pkg: ExecutionPackage) => string }; }
class SupervisingDispatcher implements ExecutionDispatcher {
  constructor(child: ExecutionDispatcher, options?: { policy?: SupervisionPolicy; now?: () => number; sleep?: (ms: number) => Promise<void> })
}
class CircuitOpenError extends Error { readonly key: string; }
// (L20) a superseded refusal is rethrown with no restart and does not count against the circuit
// (an earlier genuine failure in the same dispatch still does).

@loom/plugin-worker-remote — the HTTP client side

class RemoteWorkerRuntime implements WorkerRuntime {
  // POSTs an ExecutionPackage to a `@loom/plugin-worker-server` endpoint. The package carries
  // resume COORDINATES, never code: the worker rebuilds all state from the shared stores.
}

@loom/plugin-worker-server — the stateless HTTP worker

function createWorkerServer(worker: WorkerRuntime, options?: { port?: number; authorize?; onError?; onSuperseded?: (executionId: string, err: unknown) => void }): WorkerServer   // POST /execute, POST /cancel, GET /health
// POST /execute answers a superseded refusal (L20) with 409 { error: "execution superseded", code: "superseded", executionId };
// RemoteWorkerRuntime maps exactly that (code AND executionId) to ExecutionSupersededError. Other failures: opaque 500 + errorId.
function startWorkerServer(worker: WorkerRuntime, options?: { port?: number; host?: string; authorize? }): Promise<WorkerServer>  // port ← env.PORT ?? 8080
interface WorkerServer { start(): Promise<void>; stop(): Promise<void>; readonly url: string; readonly port: number; }

@loom/plugin-cloudrun

class CloudRunWorkerRuntime {
  constructor(platform: CloudRunPlatform, options?: { sizing?: SizingPolicy; requestTimeoutMs?: number; fetchImpl? })
}
interface CloudRunPlatform {
  resolveEndpoint(config: CloudRunConfig): Promise<string>;   // load-bearing
  release?(endpoint: string): Promise<void>;
  identity?: IdentityProvider;   // getIdentityToken(audience): sent as Authorization: Bearer on every request,
                                 // audience = the service URL's origin; a failing provider fails the dispatch
  secrets?: SecretProvider;      // a slot for your own adapter and effects: the runtime does not read it
  logs?: LogSink; metrics?: MetricSink;   // logs on allocate; metrics records cloudrun.execute.ms
}
interface CloudRunConfig { cpu: number; memoryMb: number; concurrency?: number; }
type SizingPolicy = (meta?: DispatchMetadata) => CloudRunConfig;   // defaultSizingPolicy exported
class StaticCloudRunPlatform { constructor(endpoint: string, options?: { secrets?; identity?; logs?; metrics? }) }

See the Cloud Run deploy guide (loom/docs/plugins/cloudrun/deploy.md).

@loom/graph-executor — distributed fan-out

function executeGraph(dispatcher: ExecutionDispatcher, nodes: GraphNode[], options?: {
  parentExecutionId?: string; limit?: number;
  branchRegistry?: BranchRegistry;                 // records parent→child lineage
  executionIdFor?: (node: GraphNode) => string;
  principal?: Record<string, unknown>;             // the caller; without one the dispatch is anonymous (refused under strict)
}): Promise<GraphExecution>
interface GraphNode   { id: string; flowId: string; input?: unknown; metadata?: DispatchMetadata; }
interface GraphExecution { nodes: GraphNodeResult[]; byId: Record<string, ExecutionResult>; completed: boolean; }

Each node becomes its own execution (one writer per log), fanned out via parallelLimit and joined via lineage.

@loom/scheduler — ephemeral cooperative scheduler

enum Priority { Immediate = 0, UserBlocking = 1, Normal = 2, Idle = 3 }   // lower = more urgent
function createScheduler(config?: { concurrency?: number; onEvent?: (e: SchedulerEvent) => void }): Scheduler
interface Scheduler { schedule<T>(task: (signal: AbortSignal) => Promise<T>, opts?: { priority?: Priority; signal?; label? }): Promise<T>; readonly inFlight: number; readonly pending: number; }

function parallelLimit<T>(tasks: ReadonlyArray<(signal: AbortSignal) => Promise<T>>, opts?: { limit?: number; signal?; failFast?: boolean }): Promise<T[]>  // input-order-preserving
function yieldControl(): Promise<void>
// AbortSignal utils: NEVER_SIGNAL, signalReason, throwIfAborted, linkSignals, withTimeout(ms, parent?) => { signal, dispose() }

DX & tooling

Each of these is a lens on the durable event log; all are exposed both as libraries and (for the log tools) via the loom CLI.

@loom/analytics — event intelligence (2.0)

Pure, read-only analyzers over the log (never a writer), plus an advisory runtime-intelligence report. Mines the facts M1–K3 write (RESOURCE_METERED, DECISION_RECORDED, EFFECT_DENIED, QUOTA_EXCEEDED, STEP_*).

function analyze(events: EventEnvelope[], options?: { dimensionUnits? }): Analytics;   // { execution, cost, reasoning, failure }
function executionAnalytics(events): { steps; completed; failed; cancelledSteps; suspended; resumes; status? }
function costAnalytics(events, { dimensionUnits? }?): { tokens; costUsd; ms; meteredCalls; quotaExceeded; custom?; customReport? }
// custom = ADR 0115 open dimensions summed (<=32 names per report, rest under "(other)"); also resourceGraph().total.custom / byStep[].custom
function reasoningAnalytics(events): { decisions; routes: Record<string, number>; elevations; bySource: Record<string, number> }
function failureAnalytics(events):   { failures; denials; quotaExceeded; failedSteps: string[]; deniedTools: string[] }

// M10 self-optimization (mining) — feed rankModels() into @loom/reasoning rankedRouter()
function rankModels(events: EventEnvelope[]): Record<string, number>;    // model-route frequency
function hotFailures(events: EventEnvelope[], limit?: number): string[];  // flakiest steps, most-frequent first

// M11 runtime intelligence — an ADVISORY report; never mutates the fold
function runtimeIntelligence(events: EventEnvelope[], options?: { budgetHeadroom?: number; failureLimit?: number }): {
  analytics: Analytics;
  recommendations: { modelRankings: Record<string, number>; hotFailures: string[]; suggestedBudgetUsd?: number };
}

@loom/appgen — AI-native, brownfield-aware codegen (2.0)

Natural language → a delta against the existing app → plain, static, inspectable TypeScript you own. No runtime interpretation of NL. Four stages, NL isolated to one (see ADR 0042).

function scanApp(root: string): AppModel;                              // deterministic scan (flows/steps/gates/handlerState)
function planChangesDeterministic(intent: string, appModel: AppModel): ChangeSpec;  // the one NL stage (swappable for a model)
function applyChanges(changeSpec: ChangeSpec, appModel: AppModel): FilePlan;       // pure, surgical, formatting-preserving
function typecheck(dir: string): TypecheckResult;                    // in-process tsc

type Op = "create" | "extend" | "modify" | "delete";
interface ChangeSpec { name: string; capabilities: CapabilityChange[]; compute?: ComputeSpec; planner: "deterministic" | "model"; elevations?: { from: string; to: string; reason: string }[]; }
interface FilePlan { root: string; isNewProject: boolean; creates; edits; deletes; skips; regenRegistration: boolean; }

Grammar: “create a <name>” · “(add )?step <name>” · “delete <name>” / “delete step <s> from <flow>” · “grant <tool> to <cap>” · “use <model> first” · “elevate to <model>” · “(pause|wait) for approval” · “generate ui” · “deploy on <target>”. Safety gate: a handler body is overwritten only while it still hashes to the generated stub. Every apply records a replayable .loom/appgen/<ts>.spec.json lockfile. See the DX deep-dive (loom/docs/loom-2.0/05-capabilities-agents-intelligence-dx.md).

@loom/appgen-model — optional local-model planner (2.0)

Swaps a small on-device Gemma model (via node-llama-cpp, an optional dep) into appgen’s plan stage: grammar-constrained ChangeSpec JSON, the @loom/reasoning cheap-first elevation ladder, and a self-repair loop. Fully optional and offline — falls back to the deterministic parser whenever the model is missing, too weak, or produces an invalid plan.

function planChangesModel(intent: string, appModel: AppModel, opts: { routes: ModelRoute[]; decide?: DecideFn; temperature?: number; maxRepairs?: number }): Promise<ChangeSpec>;
function gemmaRoute(modelPath: string, tierKey?: string): ModelRoute;   // tierKey a key of GEMMA_TIERS; default DEFAULT_TIER = "e4b"
class GemmaProvider implements LlmProvider { /* node-llama-cpp, load/unload per call */ }

@loom/cli — the loom command

The command set is documented — and kept in sync with loom --help — in the CLI reference. It is deliberately not re-listed here: a second copy of the command table is a second thing to forget. Broad strokes:

GroupCommands
scaffold & wiregen, new, appgen
validate before you rundoctor, check, verify, lint claim-check
observe / analyze a runlogs, debug, inspect, profile, introspect, optimize
learn from the loglearn report, learn eval
ship safelyeval, deploy check, compat, kernel-manifest
data protectionretention run, sweep content, compliance report
author a pluginplugin seams / new / check / try / search
list / triage executionsls (--waiting-on <event> filters suspended runs by the gate they wait on), census (what is stuck, and is anything watching it), snapshot show (a stored snapshot, decoded)
act through your runtime (dry run unless --apply)start, resume, cancel, recover, branches retire / branches prune, repair blank-stream / repair terminal-event
knowledge corpusknowledge sync / list / search
dev loop & configdev (gen —watch + run the app + the inspector), migrate-config (flat defineConfig options into their groups)

Every command has a pure, testable core exported from @loom/cli: foldAt(events, sequence?), formatLog(events, redact?), describeState(state, redact?), evaluateGoldens(goldens), buildTrace(events), checkManifest(manifest), discoverFlows(dir, skipped?) / cmdGen(dir, root?) / cmdNew(kind, name, dir, template?, root?), loadLogs(dir) / runDeployCheck(logs, resolver), serveInspector(eventStore, { port?, host? }), parseArgs(argv).

@loom/testing — the testing kit

type TestAppConfig = Omit<LoomConfig, "storage"> & Partial<Pick<LoomConfig, "storage">> & { clock?: boolean | number | TestClock };
function createTestApp(config?: TestAppConfig): LoomApp                      // defaults storage to in-memory; `clock` = time travel (execution.now + InMemoryTimerStore, autostart false)

// time travel (needs createTestApp({ clock }))
interface TestClock { now(): number; advanceBy(ms: number): void; advanceTo(epochMs: number): void }
function createTestClock(startEpochMs?: number): TestClock                   // default start 2030-01-01T00:00:00Z
function advanceTime(app: LoomApp, ms: number): Promise<void>                // advance the clock, then fireDueTimers
function fireDueTimers(app: LoomApp): Promise<void>                          // app.timerService.tick(clock.now()) - the real TimerService
function clockOf(app: LoomApp): TestClock | undefined

function waitFor(pred: () => boolean | Promise<boolean>, options?: { timeoutMs?: number; intervalMs?: number }): Promise<void>
function waitForPhase(pr: ProjectionRuntime, surface: string, executionId: string, phase: string, options?): Promise<void>
function getSurfaceState<T>(pr: ProjectionRuntime, surface: string, executionId: string): T
function driveToSuspend(handle: ExecutionHandle): Promise<unknown>
function resumeWith(runtime: WorkflowRuntime, executionId: string, event?: ResumeEvent, options?: RunOptions): Promise<ExecutionHandle>
function captureGolden(eventStore: EventStore, executionId: string): Promise<EventEnvelope[]>

// K9.5 crash injection — wrap a store so a test can kill an execution between an effect completing and its event reaching the log
function crashingEventStore(inner: EventStore, options: CrashingEventStoreOptions): CrashingEventStore
interface CrashingEventStoreOptions { dropFrom: (envelope: EventEnvelope) => boolean }   // that envelope AND every later append are swallowed
interface CrashingEventStore { store: EventStore; crashed: boolean; dropped: EventEnvelope[]; restore(): void }   // restore() = "reboot": stop swallowing before the recovery runtime is built
// A swallowed write RESOLVES, so the journal treats it as durable and onDurableEvent reports it: this harness cannot test that onDurableEvent only reports
// what the log holds (ADR 0147). Use a write that rejects, or one that never resolves.

// VCR LLM provider — record once, replay deterministically (no live calls)
interface LlmCassette { entries: Array<{ key: string; response: LlmResponse }>; }
function emptyCassette(): LlmCassette
function hashRequest(request: LlmRequest): string
function recordingProvider(real: LlmProvider, cassette: LlmCassette): LlmProvider
function replayingProvider(cassette: LlmCassette): LlmProvider

@loom/eval — log-as-fixture evals

function replayAsFixture(log: EventEnvelope[]): ExecutionState
function recordedStepOutputs(log: EventEnvelope[]): Record<string, unknown>
function diffRuns(golden: EventEnvelope[], candidate: EventEnvelope[]): RunDiff[]
function scoreRun(state: ExecutionState, scorers: Array<(state: ExecutionState) => ScoreResult>): ScoreResult[]

The LLM evaluation harness (R18, ADR 0124).

runEvalSuite({ name, cases: EvalCase[], subject, graders: (Grader | { grader, weight?, required? })[], passScore?, concurrency?, versions? }): Promise<EvalSuiteReport>
//   a pure function of its inputs: no clock, no random; cases reported in order; errored cases score 0 and stay in the mean
exactMatch(o?) / regexMatch(re, o?) / jsonSchema(schema | zod, o?)   // an unsupported JSON-Schema keyword FAILS the case
llmJudge({ rubric, judge: (prompt) => Promise<string> | { decide: ctx.decide, toolId }, passScore? })   // decide form: authorized + recorded + replayed (ADR 0119)
class CassetteProvider implements LlmProvider { constructor(opts?: { mode?: "replay" | "record"; inner?; data? }); toJSON() }   // replay never calls a model; a miss throws CassetteMissError
stubProvider(fn); flowSubject({ start }); reportDigest(report); compareReports(baseline, candidate, { tolerance? }): Regression[]; formatSuiteReport(report)

@loom/deploy-sim — deploy simulation

function simulateDeploy(logs: EventEnvelope[][], next: FlowResolver): CompatibilityReport
interface FlowResolver { getFlow(flowId: string): { definition: { version: string; steps: Array<{ id: string }> } } | undefined; }  // a booted WorkflowRuntime satisfies this
interface CompatibilityIssue { executionId: string; workflowId: string; severity: "breaking" | "warning" | "info"; kind: "flow-missing" | "step-removed" | "suspended-carryover" | "flow-code-drift"; detail: string; }
interface CompatibilityReport { checked: number; issues: CompatibilityIssue[]; ok: boolean; }

@loom/scaffold — codegen & templates

function pascal(name: string): string
function generateRegistration(flowPaths: string[], agentPaths?: string[], toolPaths?: string[], skillPaths?: string[], schedulePaths?: string[], memoryPaths?: string[], projectionPaths?: string[]): string    // → the text of loom.gen.ts (static modules)
function newFile(kind: "flow" | "agent" | "tool" | "skill" | "schedule" | "memory" | "projection" | "sentinel", name: string, template?: FlowTemplate): { path: string; content: string }
function appFiles(appName: string): Record<string, string>      // used by create-loom-app
// plus flowFile / toolFile / agentFile / newFiles / sentinelFile

@loom/compiler — build-time enhancement (optional)

function loomPlugin(): Plugin                                    // Vite plugin: handler$ extraction, flow-graph analysis
function buildManifest(state: PluginState, outDir?: string, root?: string): LoomManifest
interface LoomManifest { version: "1"; flows: Record<string, ManifestFlow>; skills: Record<string, ManifestSkill>; projections: Record<string, ManifestProjection>; flowModules: string[]; }

// Source analyses the transform emits into each flow's manifest entry, auto-injected by `loom verify`:
function analyzeNonDeterminism(source: string, location?: string, options?: AnalyzeOptions): NonDeterministicCall[]  // → `nonDeterministic`
function analyzeLoopIdentity(source: string, location?: string): LoopStepIdentityFinding[]                          // → `loopIdentity` (ADR 0061)
function analyzeRunnerReach(source: string, location?: string): RunnerReach                                         // → `runnerReach`  (ADR 0086)
interface AnalyzeOptions { skipMemoized?: boolean }   // whole-module callers skip memoized handler$ bodies
interface RunnerReach { steps: RunnerStepRef[]; unresolved: UnresolvedReach[]; suspends: RunnerSuspend[]; }
// Serialization utils: fnv1a32(input) / shortHash(input).  CLI bin: `loom-compile`.

@loom/verify — pre-flight rules over a config

Static checks that run before anything executes, over the app’s own declarations plus (optionally) the compiler manifest. Findings, with severities — loom verify exits non-zero on an error.

function verify(input: VerifyInput, options?: { rules?: readonly RuleName[] }): VerificationReport   // the pure core; LoomApp.verify({ lint? }) builds the VerifyInput from the app
function runVerification(mode: "off" | "warn" | "error", buildInput: () => VerifyInput, options?: VerifyOptions, log?: (message: string) => void): VerificationReport | null   // null when mode is "off"
function formatReport(report: VerificationReport): string
const RULE_ORDER: readonly RuleName[]  // determinism · replay-safety · suspension-safety · capability-compat ·
                                       // placement-compat · resource-compat · classification-reachability · effect-class
const RULES: Record<RuleName, Rule>
class VerificationError extends Error { readonly report: VerificationReport }

The rule table, what each one reads, and which are opt-in live in the CLI guide and packages/verify/README.md; ruleDocs.test.ts fails the build if a rule ships without reaching both.

@loom/replay-inspector — a read-only lens over a stored log

Strictly read-only (ADR 0071): it reconstructs, never writes, and never falls back to a current version when a pinned one is unavailable — a plausible wrong answer is the failure a pin exists to prevent.

class ReplayInspector {
  constructor(options: { events: ReadOnlyEventStore; snapshots?: ReadOnlySnapshotStore; projections?: readonly ProjectionSource[]; redactPaths?: readonly string[]; maxEvents?: number; diff?: DiffOptions })
  stateAt(executionId: string, position: number): Promise<InspectedState>      // { position, state, pin: VersionPin, foldedEvents }
  timeline(executionId: string, positions: readonly number[]): Promise<InspectedState[]>
  pinAt(executionId: string, position: number): Promise<VersionPin>             // VersionPin = { status: "pinned" | "unpinned-legacy"; workflowId; workflowVersion; flowHash? }
  transitionAt(executionId: string, sequence: number): Promise<Transition>      // { event?, before, after, delta, frames, projections }
}
// the same machinery as free functions
function reconstructAt(events: ReadOnlyEventStore, snapshots: ReadOnlySnapshotStore | undefined, executionId: string, position: number, options?: { maxEvents?: number }): Promise<ReconstructionPoint>
function reconstructMany(events, snapshots, executionId: string, positions: readonly number[], options?): Promise<ReconstructionPoint[]>
function reconstructProjections(events: ReadOnlyEventStore, executionId: string, sources: readonly ProjectionSource[], position: number, options?: ProjectionReconstructOptions): Promise<ProjectionSlice[]>
function pinOf(state: ExecutionState): VersionPin
function diffState(before: unknown, after: unknown, options?: DiffOptions): DiffResult
function anchorFor(snapshots: ReadOnlySnapshotStore | undefined, executionId: string, position: number): Promise<ReconstructionAnchor>
class InspectorBudgetError extends Error {}          // a reconstruction that would exceed its budget

@loom/sdk — the public barrel

Re-exports the surface most apps import (about 480 runtime exports) so an application depends on one package rather than a dozen. It adds nothing of its own: every entry is documented under its owning package above, and a symbol absent here is absent from the barrel.

import { flow$, step$ /* … */ } from "@loom/sdk";
import { defineConfig } from "@loom/app";   // the composition root is its own package

@loom/conformance — the portable SPI kits

A backend is conformant if it passes the kit, not if it looks right. Every kit is a describe block a third-party package runs against its own implementation.

function runEventStoreConformance(factory: () => EventStore, label?: string, options?: EventStoreConformanceOptions): void
function runSnapshotStoreConformance(factory, label?): void
// a store DECORATOR: every optional method survives a full-capability inner (or is refused with a
// reason), the `keys` encryption marker survives, then the whole SPI suite runs THROUGH it
function runEventStoreDecoratorConformance(label: string, wrap: (inner: EventStore) => EventStore,
  options?: { refused?: Partial<Record<OptionalEventStoreMethod, string>>; inner?: () => EventStore }): void
function runSnapshotStoreDecoratorConformance(label, wrap, options?): void
function runBranchRegistryConformance(factory, label?): void
function runExecutionDispatcherConformance(factory, label?, options?: { overWorker?: (worker: WorkerRuntime) => ExecutionDispatcher }): void  // overWorker enables the 1.1.0 superseded-refusal case; omitted → reported skipped
function runTimerStoreConformance(factory, label?): void
function runProjectionRuntimeConformance(factory, label?): void
function runWorkerRuntimeConformance(factory, label?): void
function runLlmProviderConformance(factory, label?, opts?): void
function runCapabilityAuthorizerConformance(factory, label?): void
function runKeyStoreConformance(factory, label?): void
function runContentStoreConformance(factory, label?, options?: ContentStoreConformanceOptions): void
function runMemoryStoreConformance(factory, label?): void
function runDurableStateStoreConformance(factory, label?): void
function runReadModelStoreConformance(factory, label?): void

@loom/spec — the kernel manifest, badges and layering

function kernelManifest(): KernelManifest        // built by IMPORTING the real version constants
function checkCompatibility(deployment: DeploymentVersions): CompatReport
function majorCompatible(a: string, b: string): boolean
const CAPABILITIES: readonly Capability[]        // evidence-derived L0–L5 maturity registry
function renderMaturityTable(caps?: readonly Capability[]): string; function renderCompatibilityMatrix(m?: KernelManifest): string
function readWorkspaceGraph(root?: string): Map<string, PackageNode>
function findLayeringViolations(root?: string): LayeringViolation[]   // kernel ↛ plugins; reads the graph itself
const KERNEL_PACKAGES: readonly string[]; const SPI_PACKAGES: readonly string[]

Read-only; nothing here touches execution semantics. The generated docs/spec/ files come from this manifest via loom kernel-manifest --emit-docs.

@loom/plugin-kit — authoring a storage preset

interface CustomStorage { kind: "custom"; eventStore: EventStore; snapshotStore: SnapshotStore;
                          branchRegistry?: BranchRegistry; archiveStore?: ArchiveStore }
type StoragePreset<Options = void> = (options: Options) => CustomStorage
function defineStoragePreset<Options = void>(fn: StoragePreset<Options>): StoragePreset<Options>
// spread the result into defineConfig({ storage }) — the shape a plugin returns, so a preset
// never hand-rolls it and never drifts from the config type

@loom/telemetry

function createTelemetryHook(adapter: TelemetryAdapter): (event: EventEnvelope) => void   // wire on onEvent
function composeAdapters(...adapters: TelemetryAdapter[]): TelemetryAdapter
const ConsoleTelemetryAdapter: TelemetryAdapter;

interface TelemetryAdapter {   // all methods optional
  onFlowStart?(info: FlowTelemetryInfo): void;  onFlowComplete?; onFlowFail?; onFlowSuspend?; onFlowResume?;
  onStepStart?(info: StepTelemetryInfo): void;  onStepComplete?; onStepFail?; onStepRetry?;
}

Errors

Exported from @loom/core:

class SuspendError extends Error         { readonly condition: SuspendCondition; constructor(condition: SuspendCondition); }
class StepError extends Error            { constructor(stepId: string, cause: unknown); }
class ReplayError extends Error          { constructor(message: string); }
class WorkflowNotFoundError extends Error{ constructor(executionId: string); }
class StorageWriteError extends Error    { constructor(cause: unknown); }
  • SuspendError is thrown by HumanEffect / ctx.suspend to pause an execution — it is a control-flow signal, not a failure (never retried by middleware).
  • StorageWriteError surfaces a durable-write rejection at the next barrier.

Loom 2.0 errors (each fails the offending step; see the linked packages):

class CapabilityDeniedError extends Error        { readonly toolId: string; readonly reason: string; }       // K2 — @loom/effect-runtime
class QuotaExceededError extends Error           { readonly reason: string; }                                // K3 — @loom/middleware
class CircuitOpenError extends Error             { readonly key: string; }                                   // K4 — @loom/dispatcher
class SafetyViolationError extends Error         { readonly validatorId: string; readonly category: string | undefined; readonly reason: string; } // K6 — @loom/middleware
class EgressDeniedError extends Error            { readonly toolId: string; readonly target: string; readonly reason: string; } // K7 — @loom/isolate
class PayloadShreddedError extends Error         { /* subject key destroyed */ }                             // K10 — @loom/plugin-crypto
class FlowCodeMismatchError extends Error        { /* pinned flowContentHash drifted */ }                    // K11 — @loom/workflow-runtime
class FlowMigrationCheckError extends Error      { /* resumeAs replay-check refused */ }                     // K11 — @loom/workflow-runtime
class FlowReplayDivergenceError extends FlowDivergenceError { /* body diverged from its log (skippedSteps, beforeEvent) */ } // ADR 0113 — @loom/workflow-runtime
class CapabilityInvokeDeniedError extends Error  { readonly capabilityId: string; readonly reason: string; } // M7 — @loom/capability

Execution lifecycle & resume — thrown by @loom/workflow-runtime unless noted:

class InputValidationError extends Error         { readonly workflowId: string; readonly executionId: string; readonly detail: string; }  // validateInput refused the start input
class ExecutionAlreadyExistsError extends Error  { readonly executionId: string; readonly cause?: unknown; }   // ADR 0103 — a caller-chosen-id start found the id taken; nothing ran
class StartFencedOutError extends Error          { readonly executionId: string; readonly cause?: unknown; }   // L20 — (as a StorageWriteError's cause) a start fenced out by a newer token whose holder wrote nothing; nothing ran, RETRYABLE
class ApprovalRejectedError extends Error        { readonly executionId: string; readonly reason: string; }   // a resume carried a rejection
class ExecutionDeniedError extends Error         { readonly op: ExecutionOp; readonly executionId: string; readonly reason?: string; }  // ADR 0039 lifecycle authz
class ExecutionCancelledError extends Error     { readonly reason?: unknown; readonly kind: "cancel" | "deadline"; }  // L33 name "ExecutionCancelledError": abort reason of a deadline breach (kind "deadline"), and of cancel() when the canceller gave no Error (kind "cancel"); a caller-supplied Error is kept as-is
class DecideScopeClosedError extends Error      { readonly decisionId: string; }  // L33 name "DecideScopeClosedError": a call on a settled ctx.decide scope, and the abort reason of its unawaited calls
class ResumePayloadValidationError extends Error { readonly executionId: string; readonly eventType: string; readonly detail: string; }  // ADR 0077 resumeSchemas
class StateMigrationError extends Error          { readonly executionId: string; readonly toFlowId: string; readonly detail: string; }   // K11.5 state migration on resume
class StepTimeoutError extends Error             { readonly stepId: string; readonly timeoutMs: number; }     // K8 bounded effects
class StaleFenceError extends Error              { readonly executionId: string; readonly staleFence: number | undefined; readonly currentFence: number; }  // K9.4 — @loom/event-runtime
class EffectModeViolationError extends Error     { readonly toolId: string; readonly declared: EffectMode; readonly attempted: "journaled" | "ambient"; }   // ADR 0073 — @loom/effect-runtime

Checks, content & plugins:

class VerificationError extends Error            { readonly report: VerificationReport; }                     // checks.verify === "error" — @loom/verify
class UntrustedContentError extends Error        { readonly position: string; readonly origin: string; }      // ADR 0085 taint in an instruction slot — @loom/llm
class UnsupportedModalityError extends Error     { readonly modality: "image" | "audio" | "file"; readonly mediaType: string; readonly provider?: string; }  // ADR 0120 — @loom/llm
class OversizedInlinePartError extends Error     { readonly bytes: number; readonly limit: number; readonly scope: "part" | "request"; }          // ADR 0120 inline media over the cap (scope: per-part cap vs per-request total) — @loom/llm
class UnresolvedContentError extends Error       { readonly address: string; }                                // ADR 0120 a media ref that cannot become bytes — @loom/llm
class TaintedContentError extends Error          { readonly origin: string; }                                 // textOf() given a tainted() wrapper (top level, text part, tool_result content) — @loom/llm
class InvalidLlmRequestError extends Error       { }                                                          // ADR 0120 wrong part for the role, orphan tool_result — @loom/llm
class ContentMissingError extends Error          { readonly contentAddress: string; readonly executionId?: string; }  // ADR 0046 — @loom/plugin-content
class RevisionConflictError extends Error        { readonly key: string; readonly expectedRevision: number; readonly actualRevision: number; }  // @loom/plugin-durable-state
class StateContractMismatchError extends Error   { readonly contractId: string; readonly storedVersion: number; readonly expectedVersion: number; }  // @loom/plugin-durable-state
class TemplateDriftError extends Error           { /* pinned MCP Apps template no longer matches */ }         // ADR 0072 — @loom/plugin-mcp-app
class InspectorBudgetError extends Error         { /* read-only replay exceeded its budget */ }               // ADR 0071 — @loom/replay-inspector

See also: Architecture · Loom 2.0 deep-dives (loom/docs/loom-2.0/00-overview.md) · Usage · User Guide · ADRs (loom/docs/adr/)

Source: docs/api-reference.org in the loom repository.