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
- Authoring builders (
@loom/core) —flow$,step$,handler$,tool$,agent$,skill$,projection$,memory$,policy$+ shared types - Composition (
@loom/app,@loom/middleware,@loom/registry) —defineConfig, storage, effect middleware, the A2A bus - WorkflowRuntime —
start/resume/fork/loadState+WorkflowContext+ExecutionHandle - Replay & storage —
@loom/replay-vm,@loom/event-runtime,@loom/snapshot-runtime - Effects (
@loom/effect-runtime) —Effect,EffectRuntime, middleware, the four effect kinds, K2 capability ring + K12 delegation - Kernel hardening & security — the K1–K12 / T1–T2 ring map +
@loom/isolate(K7),@loom/plugin-crypto(K10),@loom/wire-abi(T2) - 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 - 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 - 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 - DX & tooling —
@loom/analytics,@loom/appgen,@loom/appgen-model,@loom/cli,@loom/testing,@loom/eval,@loom/deploy-sim,@loom/scaffold,@loom/compiler,@loom/telemetry - 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
ExecutionHandle & related types
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).
| Ring | Concern | Where in this reference | Durable fact |
|---|---|---|---|
| K1 | Versioned syscall ABI | KERNEL_ABI under ExecutionContext | — |
| K2 | Capability authority | CapabilityAuthorizer/GrantTable under Effects | EFFECT_DENIED |
| K3 | Resource metering & quotas | meteringMiddleware under @loom/middleware | RESOURCE_METERED / QUOTA_EXCEEDED |
| K4 | Fault supervision | SupervisingDispatcher under @loom/dispatcher | — |
| K5 | Event-schema governance | EVENT_SCHEMA_VERSION (event-schema ref (loom/docs/loom-2.0/10-event-schema-reference.md)) | — |
| K6 | Behavioral safety | safetyMiddleware under @loom/middleware | SAFETY_BLOCKED |
| K7 | Data-plane isolation & egress | @loom/isolate (below) | EGRESS_DENIED |
| K8 | Cancellation & deadlines | deadlineMs/signal under ExecutionContext | EXECUTION_CANCELLED |
| K9 | Delivery contract & fencing | idempotent (tool / effect) + WorkerLease.fence | EFFECT_INDOUBT |
| K10 | Log lifecycle & encryption | @loom/plugin-crypto (below) + redactionMiddleware | PAYLOAD_SHREDDED |
| K11 | Flow-code versioning | resumeAs/flowContentHash under WorkflowRuntime | EXECUTION_MIGRATED |
| K12 | Identity, tenancy & delegation | DelegationAuthorizer + TenantScoped* stores | APPROVAL_GRANTED |
| T1 | Durable timers & schedules | ctx.sleep/schedule$ + defineConfig({ timers }) | TIMER_FIRED |
| T2 | Wire-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/callcame from the model or a rendered View — so a server cannot derive it from the request.audienceOfis 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
journaledtool 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 theauthorizegate — 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.GETon the MCP endpoint itself still returns 405, which is correct for a server offering no SSE stream. - Version negotiation.
initializenegotiates 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 than2024-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:
| Group | Commands |
|---|---|
| scaffold & wire | gen, new, appgen |
| validate before you run | doctor, check, verify, lint claim-check |
| observe / analyze a run | logs, debug, inspect, profile, introspect, optimize |
| learn from the log | learn report, learn eval |
| ship safely | eval, deploy check, compat, kernel-manifest |
| data protection | retention run, sweep content, compliance report |
| author a plugin | plugin seams / new / check / try / search |
| list / triage executions | ls (--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 corpus | knowledge sync / list / search |
| dev loop & config | dev (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); }
SuspendErroris thrown byHumanEffect/ctx.suspendto pause an execution — it is a control-flow signal, not a failure (never retried by middleware).StorageWriteErrorsurfaces 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.