Specification
Loom Kernel Specification
Status: Normative. This document defines what the Loom kernel guarantees — the stable contract that implementers, SPI/adapter authors, and downstream frameworks (GenUI, AetherOS) may rely on. The ADRs (
loom/docs/adr/) explain why each decision was made; this is the what. Version: tracks the kernel manifest (loom kernel-manifest), the machine-readable projection of this document. Every guarantee here is anchored to a versioned constant + a conformance test. Conventions: the key words MUST / MUST NOT / SHOULD / MAY are used per RFC 2119.
0. Scope
Loom is a durable execution kernel: a minimal, event-sourced core that runs long-lived, suspendable, replay-deterministic executions for intelligent software, and exposes pluggable seams (storage, compute, effects, policy) around it. This spec covers the kernel and its SPIs. It does not cover application concerns (UI/blueprints → GenUI) or autonomous-cloud policy (→ AetherOS).
1. Principles & invariants (normative)
The kernel is bound by these at all times. Full definitions: architecture.md
(P1–P8) and loom-2.0/00-overview.md (loom/docs/loom-2.0/00-overview.md) (P9/P10). The two most
load-bearing, plus the global guardrails that gate every additive capability:
- P2 — Deterministic replay. Folding a stored event log MUST reconstruct identical state.
state = fold(events)is a pure function; the fold MUST NOT read wall-clock, randomness, or any ambient input. (Anchor:packages/replay-vm/src/foldEvents.ts.) - P9 — Non-determinism only when logged. A non-deterministic choice (model routing, sampling,
optimization) MUST be recorded as a
DECISION_RECORDEDevent and served from the log on replay, never re-computed. (Anchor:ctx.decideinpackages/workflow-runtime/src/WorkflowContext.ts.) - P10 — Minimal kernel. The kernel carries mechanism; policy (which hosts, tenants, budgets, models) lives above it. Enforcement rings and optional runtimes are opt-in.
- G1 (guardrail). Every additive capability MUST preserve P2 + P9.
- G2 (guardrail). Every additive capability MUST be opt-in: unwired, behaviour is byte-identical to the kernel without it (zero overhead when disabled — see §10, and the fitness gate in fitness-functions.md).
2. Execution model
- The unit of execution is an Execution — an event-sourced instance of a flow, identified by a
stable
executionId. Its folded form isExecutionState(packages/core/src/types/execution.ts):{ status, frames, variables, sequence, startedAt, updatedAt }. Per-task lifecycle lives inExecutionFrame. - The kernel exposes execution to flow code through the
ExecutionContextsyscall surface. The authoritative, versioned syscall set isKERNEL_SYSCALLS(packages/workflow-runtime/src/abi.ts), versionKERNEL_ABI_VERSION, pinned byabi.test.ts. The syscalls:execute,run,decide,parallel,suspend,sleep,sleepUntil,emit,stream,ui.KERNEL_ABI1.3.0 reserves Wave 2’scontinueAsNew,receiveandselect(all three have landed, each behind itskernel.featuresflag): a syscall is available iff it is inKERNEL_SYSCALLS, which the wire handshake (WIRE_ABI.syscalls) advertises next tokernelAbiVersion, so clients feature-detect against that list and never infer a syscall fromkernelAbiVersion >= 1.3.0. ctx.execute(task, input)is the generalized durable primitive; on replay a completed task is served from the log and its effect MUST NOT re-run.ctx.run(step, input)is the step-shaped alias. Task kinds (step/llm/agent/tool/ui/…) resolve through the effect boundary bytoolId; the wire events staySTEP_*so existing logs replay byte-identically.- Compatibility (K1): adding a syscall is a MINOR bump (
sincerecorded per syscall); removing or repurposing one is a MAJOR break.abi.test.tsfails on undocumented drift.
3. Replay semantics
- Resume = re-run, served from the log. Resuming an execution re-executes its flow runner; each
ctx.execute/run/decide/suspend short-circuits from the recorded log, so completed work is never repeated and the orchestration between recorded points re-runs deterministically. - Flow-level emits are served from the log only when the log proves them (ADR 0102).
emit,streamanduiappend ONCE per execution.- Identity.
d = fnv1a64(segment | canonical JSON of [eventType, payload]). The segment is the number of gate passthroughs consumed at the emit. The canonical form is the persisted JSON form with object keys sorted. - Stamp and fold. A stamped emit carries
metadata.flowEmit = d. The fold counts stamped emits perdinvariables._flowEmits. - Serving. On a re-run, the n-th emit of
dMUST NOT be appended iff the log holds more than n stamped emits ofdAND no step or decision of the current segment has been executed live in this run (i.e. not served from the log). Otherwise it MUST be appended. - Stamping. An appended emit MUST be stamped only when all of these hold:
- it exceeds the logged count;
- none of the run’s own effects or deciders is executing (an effect counts until it settles);
- no step or decider earlier in the run ended in an outcome a re-run does not serve (an untyped failure, a timeout, a cancellation, an in-doubt park, a throwing decider).
- Never timing. Skip decisions MUST NOT depend on timing or interleaving.
- Residual. An emit can be served in place of a new one only if a code change moves identical content within a segment whose syscalls are all served, if flow code violates the determinism contract (ADR 0057/0061), or on a digest collision.
- Unmatchable payloads. A payload with no JSON form is appended unstamped.
- Kernel facts (
STEP_*,DECISION_RECORDED,EFFECT_INDOUBT,EXECUTION_*) andEffectContext.emitare never stamped. - Legacy logs. An unstamped (pre-1.19.0) log counts nothing, so its pre-resume emits are appended once more, stamped where eligible.
- Identity.
- The fold is copy-on-write and never mutates in place a caller-visible state; audit lists
grow via structural sharing within a single fold pass (
packages/replay-vm/src/eventHandlers.ts). - Snapshots (
SnapshotStore, §7) bound the replay tail:state = fold(snapshot, suffix)MUST equalfold(events). Snapshots are out-of-band (there is no snapshot event in the log). - Determinism is enforced by golden-log replay (
@loom/evalreplayAsFixture/diffRuns), the wire fold-vectors (packages/wire-abi/src/wire-abi.test.ts), and deploy-simulation (@loom/deploy-sim).
4. Event model (K5)
- The event log is the kernel’s on-disk ABI. The set of event types the fold understands is
knownEventTypes()at versionEVENT_SCHEMA_VERSION(packages/replay-vm/src/eventHandlers.ts), pinned byeventSchema.test.ts. The catalogue with payload shapes is 10-event-schema-reference.md (loom/docs/loom-2.0/10-event-schema-reference.md). - Every event is an
EventEnvelope(packages/core/src/types/event.ts):{ id, workflowId, executionId, eventType, sequence, timestamp, payload, metadata? }. - Fold contract: an unknown event type is inert (ignored); a handler MUST be a pure
(state, event) => state; a__proto__container key MUST be rejected (fold hardening). ExecutionstatusMUST be moved only by execution-level events (EXECUTION_STARTED,_SUSPENDED,_RESUMED,_COMPLETED,_FAILED,_CANCELLED); aSTEP_*event folds into its frame only. In particularSTEP_FAILEDMUST NOT fail the execution — the flow may catch it — and onlyEXECUTION_FAILEDdoes (ADR 0105, EVENT_SCHEMA 1.21.0). ASTEP_FAILEDthat lands while the execution issuspendedMUST setvariables._failedWhileParked; whatever ends the park (EXECUTION_SUSPENDED,EXECUTION_RESUMED,EXECUTION_COMPLETED/_FAILED/_CANCELLED) MUST remove it; and anullEXECUTION_RESUMEDwhile it is set MUST be status-only (fail-closed). A caller’snullgate answer in that position MUST be refused at ingress (ResumeRefusedError,null-answer-after-parked-failure), nothing appended. A snapshot folded before 1.21.0 MUST be read throughnormalizeSnapshotState. For every log and every sequence k, folding the snapshot at k plus the tail MUST equal folding the whole log. For a snapshot written by a pre-1.21.0 fold this holds except for two shapesnormalizeSnapshotStatecannot tell from a genuine failure (ADR 0105 D4): anEXECUTION_FAILEDwithouterror(never kernel-written), and an execution that genuinely failed, was re-opened byresumeAs(which keeps the stale_error) and then caught a step failure. The remedy for both is to delete that execution’s snapshots, which makes the log authoritative. - Compatibility (K5): adding an event type or an optional payload field is a MINOR bump
(folds treat absence as legacy); removing/renaming a type is a MAJOR break. Both the version and
the pinned type set MUST change together. The current version is 1.26.0 — after the
K10.1/T3 audit facts (1.13.0/1.14.0), the perpetual-agents series added two additive MINORs
with no new event kinds: 1.15.0 extends
EXECUTION_MIGRATEDwith an optionalmigratedInputpayload field (K11.5 contract-versioned state migration — the fold replacesvariables._inputwith the recorded transformed value, ADR 0056), and 1.16.0 folds the orderedvariables._resumePayloadslist from existingEXECUTION_RESUMEDevents (the multi-gate suspend fix, ADR 0057). The GenUI/DE series added 1.17.0 (ADR 0060, again no new event kind): optionalchannel/payloadfields onSTEP_FAILED— a typed failure on a contract-declared error channel, folded into the frame aserrorChannel/errorPayload. The learning substrate added 1.18.0 (ADR 0079, no new event kind): optionalcontextandpolicyfields onDECISION_RECORDED— the observation a policy saw and the policy that saw it (with the propensity that makes unbiased off-policy evaluation possible), folded verbatim into_decisionsand never read back bygetDecision. A kernel replay fix added 1.19.0 (ADR 0102, no new event kind, no payload change): the fold stepvariables._flowEmits: the count of stamped flow-level emits per (gate segment, content) digest, keyed on the envelope stampmetadata.flowEmitfor ANY event type, so flow-levelemit/stream/uiappend once per execution (§3). ADR 0104 added 1.20.0 (no new event kind, no payload field): anEXECUTION_RESUMEDwhose payload isnulland which answers no suspend point — the execution was not parked, or was parked in doubt — re-opens the execution WITHOUT adding a gate passthrough. ADR 0105 added 1.21.0 (no new event kind, no payload field):STEP_FAILEDno longer moves the execution status — a log that ends after a caught step failure now foldsrunning(it wasfailed), and a parallel sibling’sSTEP_FAILEDafter the flow parked leaves itsuspendedwith the new reserved key_failedWhileParked, under which anullresume stays status-only exactly as 1.20.0 folded it. Every gate passthrough is therefore unchanged from 1.20.0; the status and the new key are what differ. ADR 0107 added 1.22.0, one new event kind:EVENTS_REHOMED, the lifecycle-stream fact the operator repair of the pre-0106 blank stream journals per segment it re-homes. Pure audit into_lifecycle; no existing fold changes. Wave 2 (W2-0, ADRs 0109–0116 and 0118) added 1.23.0: every new fold handler and reserved shape, with no kernel writer yet. Two new kinds,EXECUTION_CONTINUED(terminal statuscontinued,_continuedTo) andMESSAGE_CONSUMED(_inbox,_waitAnswers); a payload forPARALLEL_COMPLETED(_scopes); and optional fields onEXECUTION_STARTED(_principal,_parent,_chain,_codeHash, and a validated carry that seeds_resources/_inbox),EXECUTION_SUSPENDED(waits[],_waits), a wait-addressedEXECUTION_RESUMED(_waitAnswers),RESOURCE_METERED(custom) andOUTCOME_RECORDED(recordedBy,late). The fold now reads three more envelope metadata keys —kernel,forkChain,forkedBy— and the new kinds, thePARALLEL_COMPLETEDpayload and a wait-addressed resume fold ONLY a kernel-stamped (metadata.kernel: true) envelope, so an application event that already carries one of those names in an existing log folds as the no-op it always was.ExecutionStatuswidens bycontinued. The shapes are frozen at W2-0: a later change is an additive schema change. See the Wave 2 design document (docs/requirements/2026-10-05-wave2-kernel-design.md). R7 (ADR 0113) is the first writer of one reserved key:EXECUTION_STARTED.codeHash(_codeHash) is recorded when the flow supplies acodeFingerprint.flowHashmay besha256:<hex>(a recordedfnv1a:value stays valid; new starts recordsha256:only behindkernel.features.sha256FlowHash); two additive optional payload fields within 1.23.0:DECISION_RECORDED.innerSteps(kernel-stamped only) andEXECUTION_MIGRATED.toCodeHash; a null resume of the reserved gate__loom_diverged__is status-only like the in-doubt gate. The replay-divergence detector itself is runtime-only; infailmode a refusal writes an ordinaryEXECUTION_SUSPENDEDon the reserved gate. ADR 0128 added 1.24.0, one additive field and no new kind: each folded_decisionsentry carriesseq, the envelopesequenceof the FIRSTDECISION_RECORDEDfor that decision id (taken from the envelope, never the payload; a repeated id keeps its firstseq). It exists so the ORDER decisions were made in survives a checkpoint: the map’s key order does not (array-index-like keys sort first; a normalising snapshot store such as Postgresjsonbreorders keys). It is descriptive, never a replay input (getDecisionstill returns onlychoice). A snapshot written by a 1.23 fold has noseqon its absorbed entries: those are LEGACY, read in folded-map order, and are NOT migrated (normalizeSnapshotStatenever invents aseq) — the same documented exception tofold(snapshot, tail) == fold(events)as the 1.21.0 shapes. Event bytes and hash chains are unchanged;WIRE_ABI_VERSIONstays 1.0.0 (ADR 0026 versions the wire protocol, and added fold vectors are not a protocol change; a foreign fold that does not stampseqfails the vector). ADR 0130 residuals reserved 1.25.0, with NO writer yet (the shapes are frozen, the handlers present, a log written before it folds byte-identically): the envelope keymetadata.seal({ v: 1, cipher, covers }, a sealing store’s marker; the fold IGNORES it), the kindsINBOX_QUARANTINEDandINBOX_RELEASED(a per-topic hold on an inbox message and its order-preserving release; folded only from ametadata.kernelenvelope; bothignorablefor the park fence;INBOX_RELEASEDcarries exactly one ofoperator/auto,autoonly withdeliver, and a fact with both or neither is ignored), the fold variable_quarantined(queue per topic, shapereleased? held*, at most 1 024 entries), an additiveMESSAGE_CONSUMEDstep that drops a delivered released entry (gated on_quarantined), and the carrycontinuedFrom.carried.quarantined.KERNEL_ABI_VERSIONstays 1.3.0. ADR 0138 added 1.26.0, no new kind and no payload change: a frame’sinputis set bySTEP_STARTED(kept bySTEP_RETRYING) and DROPPED when the frame settles (STEP_COMPLETED,STEP_FAILED,STEP_CANCELLED), because nothing reads a settled frame’s input from the fold and the log (or the compaction archive) holds it. The folded bytes differ (state is linear in turns);normalizeSnapshotStatestripsinputfrom the settled frames of an older snapshot in one O(frames) pass, sofold(snapshot, tail) == fold(events)holds. Documented mixed-fleet exception, as 1.24.0: a pre-1.26 node re-adds an input to a frame it settles, harmlessly. The wirestateView,executionFromState, the inspector’s state pane andReplayInspector.framesTouchedtherefore no longer show a completed step’s input; read it from the log (the timeline) orLogLifecycle.readArchived. Logs that never migrate state, resume twice, fail typed, record a policy, stamp a flow emit, or resumenullinto an unparked or in-doubt execution, hold aSTEP_FAILEDthat no status-setting event follows, or record anyDECISION_RECORDED(the folded state now carriesseq, so its BYTES differ from 1.23.0’s), replay byte-identically. In a mixed fleet (a rolling upgrade or rollback interleaving 1.23 and 1.24 nodes) a snapshot may hold stamped and unstamped entries together: a 1.23 node adds a decision withoutseq, or rebuilds a stamped one without it, and the entry stays unstamped (a repeat never invents aseq).evidenceEventsplaces each unstamped entry immediately after the stamped entry that precedes it in map order, so such an entry stays beside the decision it followed.
4.1 Typed failure channels (ADR 0060)
- A contract MAY declare
errors: { <channel>: schema }(step-level wins; the registered effect’s map is the fallback). ATypedFailurethrown on a declared channel is recorded asSTEP_FAILED { channel, payload }with the payload validated against the channel schema before the append, and capped at 16 KiB (over-cap, invalid-payload, and undeclared-channel failures all degrade loudly to the legacy untypedSTEP_FAILED). - Replay substitution: a frame with
errorChannelis served at the execute seam exactly as a completed frame is — theTypedFailureis rethrown with the recorded payload, the effect (and its middleware) is never re-invoked, and the flow deterministically re-takes the same catch branch. This applies to live re-invocation of the same step id too (the ADR 0061 identity rule: a deliberate retry mints a new id). Untyped failures keep the legacy at-least-once re-execution. - There is no
ctx.failsyscall —TypedFailureis a userspace throw (fail(channel, payload)in@loom/core);KERNEL_ABIis unchanged.
5. Suspension semantics
ctx.suspend(config)durably parks an execution;configisSuspendConfig(packages/workflow-runtime/src/types.ts):{ on, timeout?, correlationKey?, wakeAt? }.- Resume routing is by
executionId:WorkflowRuntime.resume(executionId, event)addresses the parked execution directly, and nothing in the runtime matches oncorrelationKey. AcorrelationKeyis OPTIONAL metadata for an external dispatcher that holds a business key instead of an execution id. (A normative “MUST carry acorrelationKey” here, and the@loom/verifyrule that enforced it, were both wrong; ADR 0086 removed the rule once the input that would have made it fire finally existed.) - A timed suspend (
sleep/sleepUntil/suspend({timeout})) MUST record a durablewakeAt, fired as aTIMER_FIREDresume that survives restart (T1). The scheduled time is in the log — wall-clock drift never enters the fold. When aTimerStoreis wired the runtime DERIVESwakeAtfrom a suspend’stimeout, sosuspend({ on, timeout })satisfies this; without one, the timed suspend is silently never woken, which is whatsuspension-safetyreports. - Resume is ingress-gated, always and un-opt-outably (#304/#305).
eventTypeis caller-supplied — A2AprovideInputand the wire-ABIexecution.resumeboth hand a network-suppliedResumeEventstraight toresume()— and the log folds by NAME, so this gate decides WHICH event may be delivered and to an execution in WHICH state:- A resume event type MUST NOT be a kernel event type (
knownEventTypes(), the K5-governed set). A caller-suppliedSTEP_COMPLETEDforges a step outcome the completed short-circuit then serves without running the effect;EXECUTION_MIGRATEDrewrites the K11 code pin and_input;APPROVAL_GRANTEDforges the very evidence the authenticator exists to produce.TIMER_FIREDis the sole exception — the kernel raises it itself. - The execution MUST be
suspended, and the event type MUST be the gate it is parked on (suspendedOn.on). A timed suspend is additionally wakeable byTIMER_FIRED, sincesuspend({ on, timeout })means either wake; that allowance is keyed on the recordedwakeAt, so an untimed gate is not wakeable by a synthetic timer event. EXECUTION_RECOVEREDis the one non-gate resume: a dispatcher re-driving a CRASHED execution, so it addresses arunning/pendingone and MUST be refused on a parked or terminal execution.- Every
_resumePayloadsentry MUST answer exactly one suspend point: actx.suspend, or akind: "human"step, which consumes its entry in order with them (live and on replay past the completed step).ctx.suspendserves the list positionally, so an entry that answered nothing is served to the flow’s NEXT gate as its approval (ADR 0104). Hence a re-drive of a crashed execution (EXECUTION_RECOVERED,resumeAsonto one) records noEXECUTION_RESUMED;resumeAsonto a terminal execution recordsEXECUTION_RESUMEDwith anullpayload (status only, 1.20.0); and an in-doubt verdict (K9.3) is recorded as a kernelDECISION_RECORDEDkeyed by the step and the attempt it answers, beside anullEXECUTION_RESUMED. A resume at an in-doubt park that is not a verdict for the parked step MUST be refused (ResumePayloadValidationError) with nothing appended. Logs written before this rule MUST be replayed as written; a runtime SHOULD warn about the entries in them that answered no suspend point, saying whether each is already spent. - A resume that arrives before the execution parks (it is still
running) is refused (ResumeRefusedError,not-suspended) with nothing appended; the caller retries once the execution is parked. Recording it would make it an answer to whatever gate came next. - All of these MUST refuse with nothing appended. Refusal is fold-identical for every legitimate log (G2): a non-kernel event type already folds to a no-op, so the resume-typed event never contributed to state unless it collided with a kernel name.
resumeAsonto a crashed or terminal execution enforces the kernel-namespace rule only: nothing is parked, and migrating a crashed execution onto fixed code is the documented exit from aFlowCodeMismatchError. Onto an execution PARKED at a gate it MUST enforce every ruleresumedoes — the gate name (the RECORDED gate), the gate’sresumeSchemasentry in the target flow, theResumeAuthenticatorand the execution authorizer — since its payload is that gate’s answer (ADR 0104).- Userspace emits (
ctx.emit/stream/uiandEffectContext.emit) MUST refuse every kernel event type exceptRESOURCE_METERED,QUOTA_EXCEEDED,SAFETY_BLOCKED,EGRESS_DENIED,EFFECT_DENIED,OUTCOME_RECORDEDandDECISION_RECORDED(ADR 0104); otherwise flow code forges kernel state the fold trusts. The allowed types still fold, so they are validated:RESOURCE_METEREDfields MUST be finite, non-negative and under a sanity ceiling;QUOTA_EXCEEDED.reasonMUST be a bounded string; an EFFECT-writtenDECISION_RECORDEDid MUST be rewritten into its step’s namespace (effect:<stepId>#<attempt>:<id>) so it never reaches the flow’sctx.decide, and no userspaceDECISION_RECORDEDmay change an already-recorded choice. An isolated tool’s relay MUST forward application event types only. - Decision ids beginning
__loom_are reserved for the kernel’s own records (an in-doubt verdict, adiscardLegacyAnswersoverride):ctx.decideand userspaceDECISION_RECORDEDemits MUST refuse them, checking the id that is folded (the payload is copied to plain data first). A verdict decision MUST only be honoured beside the kernel’sEFFECT_INDOUBTfact for the same step and attempt. - A human step’s park MUST record
condition.stepId(kernel-only;ctx.suspendstrips a caller-supplied one). A pre-0104 log in which an open human step’s answer cannot be told apart from another entry (an unattributed park after an answer, or an unspent entry that answered no gate before its first start) MUST be refused (ResumeRefusedError,ambiguous-legacy-answers) rather than guessed, unless the operator’s resume event carriesdiscardLegacyAnswers { stepId, count }AND theResumeAuthenticatorverifies that event (never onEXECUTION_RECOVERED/TIMER_FIRED).
- A resume event type MUST NOT be a kernel event type (
- Resume MAY be authority-gated: a
ResumeAuthenticatorverdict is recorded asAPPROVAL_GRANTED(K12); a rejected resume never reaches the log. That gate authenticates who may resume. - Resume MAY additionally be shape-gated: a flow MAY declare
resumeSchemas(on→ schema, ADR 0077). When an entry exists for the resume’s event type the payload MUST be parsed BEFORE anything is appended, the PARSED value (defaults/transforms applied) MUST be what is recorded, and a failing parse MUST refuse with nothing appended — leaving the execution suspended and resumable. Absent map or absent entry = unchanged behaviour (G2). Shape is checked AFTER identity, so an authenticator sees the exact event the caller signed; the ingress gate sits between them, for the same reason — its gate-mismatch refusal names the gate, which an unauthenticated caller has no business learning.resumeAsvalidates against the TARGET flow’s map. Note the shape gate is keyed on the caller’s event type, so it is load-bearing only because the ingress gate already requires that type to BE the parked gate: without it a declared schema was evaded by renaming the event. - On replay, resumed gates pass through in order, each with its own recorded payload (the
_resumePayloadsfold, ADR 0057); a gate beyond the recorded resumes parks as before. resumeAs(K11.3) MAY carry an operator-supplied state transform (K11.5, ADR 0056): the transform’s RESULT is recorded onEXECUTION_MIGRATED(migratedInput), every later resume/restore folds the migrated input, and a failing transform refuses the migration with nothing appended.
6. Placement semantics
- Placement is a kernel concern; cloud-provider logic is not (P10). An execution is dispatched
through the
ExecutionDispatcherSPI (§7) onto aWorkerRuntime. TheExecutionPackagecarries only coordinates (executionId,flowId, resume hints) — never live code; any worker reconstructs state from the shared stores. - Resume is migration. Because workers are stateless and state is durable, re-dispatch to any
worker resumes from the journal; fencing (K9.4) fences a zombie predecessor;
EXECUTION_MIGRATEDrecords a cross-code migration verdict. A pinnedcomputeTargetMUST resolve to a registered dispatcher target (checked by@loom/verifyplacement-compat).
7. SPI contracts
The pluggable seams. Each has a version constant + a conformance kit that a third-party
implementation runs to prove alignment. Full map: 12-extension-seams.md (loom/docs/loom-2.0/12-extension-seams.md).
| Seam | Interface | Version constant | Conformance kit |
|---|---|---|---|
| Event log | EventStore (@loom/event-runtime) | EVENT_STORE_SPI_VERSION | runEventStoreConformance |
| Snapshots | SnapshotStore (@loom/snapshot-runtime) | SNAPSHOT_STORE_SPI_VERSION | runSnapshotStoreConformance |
| Branches | BranchRegistry (@loom/event-runtime) | BRANCH_REGISTRY_SPI_VERSION | runBranchRegistryConformance |
| Compaction archive | ArchiveStore (@loom/event-runtime) | ARCHIVE_STORE_SPI_VERSION | runArchiveStoreConformance |
| Dispatch | ExecutionDispatcher (@loom/dispatcher) | DISPATCHER_SPI_VERSION | runExecutionDispatcherConformance |
| Worker | WorkerRuntime (@loom/worker-runtime) | — | (staged) |
| Effect / middleware / authority | Effect / EffectMiddleware / CapabilityAuthorizer (@loom/effect-runtime) | — | (staged) |
- Required vs optional: each interface marks optional methods (e.g.
EventStore.readRange,acquireFence,acquireFenceIf(the conditional fence, SPI 2.1.0, ADR 0118),appendIfUnchanged(the conditional append a late outcome needs, SPI 2.2.0, ADR 0116; since SPI 2.3.0, ADR 0129, the observationanchorMUST differ between two incarnations of an execution id even when their sequence-0 envelopes are byte-identical, so a store claiming 2.3.0 assigns a monotonic incarnation in store metadata only, never in an event),storeNow(the store’s own clock in epoch ms, SPI 2.4.0, ADR 0110 D10a: optional, additive; Postgres reports database time, so a fleet ages an inbox message on one clock; a single-process store omits it and callers fall back to their injected clock),deleteThrough(one atomic delete of every event withsequence <= through, sequence 0 included; SPI 2.6.0, ADR 0139 D8: optional, additive - a caller that archived a reserved stream up tothroughremoves exactly that, so a concurrent append survives; a caller facing a store without it refuses the delete rather than falling back todeleteExecution),sealingCapability(what the store seals and therefore opens, SPI 2.5.0, ADR 0130 D7: optional, additive, reserved - declared and forwarded by every decorator, no caller yet; absent orundefinedmeans it seals and opens nothing, andShardedEventStorereturns one only when every shard agrees; the SPI also reserves the control stream prefix__loom_quarantine__:<chainId>),copyPrefix,deleteExecution, and the pagedlistExecutionsPage— SPI 1.2.0, the bounded-memory enumeration maintenance passes prefer). An implementation MAY omit optional methods; the conformance kit skips them. A store that omitsacquireFenceperforms no fencing (single-node). - Other optional methods:
SnapshotStore.deleteExecution,loadLatestSealed(SPI 1.1.0) andpruneSnapshots(SPI 1.2.0, ADR 0134: a range delete the CALLER,LogLifecycle.pruneSnapshots, decides is safe) andlistSequences(SPI 1.3.0, the ADR 0134 amendment: the stored sequences, ascending, one per snapshot; backspruneSnapshots({ dryRun }));ArchiveStore.deleteExecutionandreplaceSegments(SPI 1.1.0, ADR 0139 D14).LogLifecycle.compactappends the exact envelopes it removes to theArchiveStorebefore it truncates and refuses without one (ADR 0138);readRange, replay and resume never read it. - Plugin-tier SPIs are pinned the same way, without kernel version constants:
ContentStore(incl. the ADR 0059putStreamoutcome contract and, since SPI 1.5.0, the optional owner argumentContentAccessplus thesealsContentmarker, ADR 0143),DurableStateStore, andReadModelStore(incl. query limit/cursor validation) each ship arun…Conformancekit in@loom/conformance. - Compatibility: an SPI version is semver; a MINOR bump adds an optional method, a MAJOR bump
changes a required-method contract. Kits import from
@loom/conformance.
8. Resource semantics
- Resources are metered per effect as
RESOURCE_METEREDand folded into a fixed-size accumulator (_resources), read viaEffectContext.resources(ResourceSnapshot). Dimensions (ResourceDimension):tokens,costUsd,ms, and (ADR 0032) optionalmemBytes,wallMs,netBytes. A dimension is present only where an effect can measure it (no false zeros). - A
BudgetMAY cap any dimension; exceeding it raisesQUOTA_EXCEEDEDvia admission control.ctx.negotiateBudget(dimension, request, opts?)resolves a bounded grant through the decision seam (recorded, replay-safe) — advisory only; it never enforces.
9. Compatibility guarantees
- The kernel’s contract versions are aggregated, machine-readable, in the kernel manifest
(
@loom/speckernelManifest();loom kernel-manifest --json). It is built by importing the real exported constants, so it can never drift from the code. checkCompatibility(deploymentVersions)validates a deployment’s declared versions: two versions are compatible iff they share a major version.loom compat --abi <v> …exits non-zero on a mismatch (a deployment gate). Loom is a lockstep monorepo today (all1.x), so the supported set is a single row (see compatibility-matrix.md); the mechanism exists for the day a MAJOR diverges.
10. Conformance & continuous enforcement
A third party proves alignment, and the kernel proves it hasn’t regressed, via executable checks — not reviewer memory:
- ABI/schema pins:
abi.test.ts(K1),eventSchema.test.ts(K5),wire-abi.test.ts(T2 fold-vectors) fail on undocumented drift. - SPI conformance kits:
run<Spi>Conformance(@loom/conformance), run against the in-memory + SQLite reference impls. - Replay-unchanged: golden-log replay (
@loom/eval) + deploy-simulation (@loom/deploy-sim). - Zero-overhead (G2): behavioural disabled-path tests + a relative-ratio bench gate
(
packages/verify/src/g2ratio.perf.test.ts, perf suite). - Maturity is self-checking:
@loom/specspec.test.tsfails if a capability’s badge claims evidence (a version constant / conformance test / bench) it does not have.
These are enumerated, with their enforcement location, in
fitness-functions.md, and gated in CI (pnpm fitness).
Appendix — normative anchors
| Guarantee | Anchor |
|---|---|
| Syscall ABI | packages/workflow-runtime/src/abi.ts (KERNEL_ABI_VERSION, KERNEL_SYSCALLS) |
| Event schema | packages/replay-vm/src/eventHandlers.ts (EVENT_SCHEMA_VERSION, knownEventTypes) |
| Fold | packages/replay-vm/src/foldEvents.ts |
| Wire ABI | packages/wire-abi/src/protocol.ts (WIRE_ABI_VERSION) |
| SPIs | packages/{event-runtime,snapshot-runtime,dispatcher,worker-runtime}/src/… |
| Suspension | packages/workflow-runtime/src/types.ts (SuspendConfig) |
| Resources | packages/effect-runtime/src/types.ts (ResourceSnapshot) |
| Manifest & compat | packages/spec/src/{manifest,capabilities}.ts |
Source: docs/spec/loom-kernel-specification.md in the loom repository.