Skip to content

@substrat-run/kernel

The kernel's behavioral seams in pure TypeScript. This package imports no platform APIs — Cloudflare specifics live only in adapters, and every adapter must pass the same conformance suite.

sh
pnpm add @substrat-run/kernel @substrat-run/contracts

Scope host (scope-host.ts)

The adapter seam. Full semantics in Operations & the scope host.

ExportKindPurpose
ScopeHostinterfacegetScope, getConnectorScope, getSystemScope (the scheduler's door — a stub whose authority is a module on a timer, #383), getImpersonatedScope (the fourth door: a stub that acts as a principal under a session HostAdmin.beginImpersonation opened and logged first — re-read on every invoke, so an ended or expired session stops working, K-42), provisionScope, registerModule, defineOperation, registeredSchedules / runDueSchedules (the recurring-work seam the platform sweep drives), admin, close
ScopeStubinterfacethe capability — the only way code outside a scope reaches it
OperationContextinterfacewhat a handler sees: ambient tenantId/scopeId/principal, sql, now (the operation's instant — the only clock module code may read, stable for the whole invocation), emit, check, search (ids from the declared FTS index), page (one kernel-composed page of a declared entity — filter, sort, keyset cursor and optional total from the operation's paged declaration), entitlement/entitlements (read a held SKU/quota at request time — the gate a CP-less vertical uses), link, grant/revoke (delegation, never elevation), requestPlatform/platformRequests, versionOf (an entity's version — the ULID of the last event about it), atomic (a sub-transaction: the only place an engine error may be caught, because a throw inside discards everything the callback wrote), sealToConnection
OperationHandler<I, O>type(ctx, input) => O | Promise<O>
ConsumerHandlertypeevent consumer; at-least-once, must be idempotent
ModuleRegistrationinterfacemanifest + migrations + operations + consumers, plus two derived maps the host enforces and the module never writes by hand: operationInputs (operationInputsOf(ops) — every invocation is parsed before the guards and the handler, on every path in) and operationConcurrency (operationConcurrencyOf(ops) — which entity's version an If-Match on each operation is checked against)
SqlMigrationinterface{ version, sql } — ordered, journaled per module
ScopedSql, SqlValuetypessynchronous scope-local SQL: query<T>(), exec()
ExecutorHandlertype(admin, event) => void — out-of-band host code effecting what a module asked for via an event (K-22). Registered with registerExecutor; receives HostAdmin, never ctx, because it acts with platform authority
HostAdmininterfacethe audited control-plane surface. Every mutation takes a PlatformActorId and writes an append-only audit row; directory reads take one too and record to the K-24 access log — and a scope read the control plane delegated to the hosted vertical holding the data leaves the same row, through recordDelegatedRead (#1470, below). The capability groups it now spans: permissions & membership (defineRole, listRoles, assignRole, unassignRole, grant, grantToOrg, grantToConnection, grantToSystem — the schedule's system-principal grant (#383), addMember, removeMember, listMembers); organizations (createOrg, listOrgs, getOrg); the vertical + version registry (registerVertical, publishVersion, admitVersion/rejectVersion, promoteVersion, bindScopeVersion, setVerticalListed, verticalServing — push-is-not-live admission, channels, and the stable serving script); the hostname map (bindHostname, setHostnameStatus, setHostnameIssuance, listHostnames, resolveHostname — the router's unlogged per-request read); tenant registry (createTenant, setTenantName, setTenantStatus, listTenants, getTenant, reapTenant); scope directory + lifecycle (listScopes, getScopeRecord, activateScope, suspendScope/unsuspendScope, archiveScope/unarchiveScope, reapScope, rewindScope); scope introspection (listScopeTables, readScopeTable, queryScope, exportScope — the read-only "Data" window into a scope's own database — plus the six event readers over the same window, entityHistory (#1353: one record's story, readHistory's answer hoisted to the platform), facetEvents (narrow, group, count — erased payloads counted apart, never folded into a "no value" bucket), eventCause and eventEffects (one event walked backwards to what started it, or forwards to what it set off; the chain ends in a named terminal, never a silent stop), and invocationEvents (#1237: everything one call emitted, siblings with no causal link included, with truncated rather than a quiet cut), and deadLetters (#1525: every delivery in the scope that gave up, newest event first — the list the walks reach only one event at a time). All six are the co-located branch; for a hosted vertical the transport reads them through the vertical's own /internal/* routes instead); the Tier-2 event drain (readUndrainedEvents, markEventsDrained — the two verbs the sweep's outbox drain is made of (#1409), deliberately separate: read, ship through the EventSink, then stamp drained_at only once the sink confirmed durability. readUndrainedEvents selects on drained_at IS NULL; markEventsDrained is what writes the stamp, so a DrainedEvent carries no drainedAt field — it is the envelope plus the three outbox-column facts the envelope cannot hold, operation, version and causedBy (each nullable, the nulls meaning what events sets out). A drained row is never pruned — consumers, replay and readHistory still read it); entitlements (grantEntitlement, revokeEntitlement, listEntitlements); the connections hub (createConnection, openConnection, listConnections, updateConnectionSecret, revokeConnection, connector-state get/put/list — the integrations credential store); identity (registerIdentityPool, linkIdentity, unlinkIdentity, resolveIdentity, listIdentityTenants, listIdentityMemberships — the same central-only question with each tenant row, the login's principal in it and the link's scope attached, as one read and one access-log row, for a caller that would otherwise ask once per tenant); denials (listDenials, summarizeDenials — a scope's refused permission checks, raw and bucketed per actor and key, K-35; see Denials are recorded); impersonation (beginImpersonation, endImpersonation, listImpersonations — the record-keeping half of acting as somebody: reason required, read-only unless said otherwise, capped at IMPERSONATION_MAX_MINUTES, admin-logged before the session can be used; the effecting half is ScopeHost.getImpersonatedScope); operational failures (recordOpsFailure, listOpsFailures — what the platform could not complete: a deploy, a restore, a provision, filed with the upstream trace reference so reference = <id> has a queryable home. Deliberately not the audit log: a failure changed nothing. Retention-bounded, so an empty read means "nothing recent", never "nothing ever"); the sweep record (recordSweepRun, listSweepRuns — the durable per-unit record of the three sweep phases that have one, #1255: each live connection swept, failed, or skipped because its provider registered no sweeper (a revoked connection is deliberately not recorded — a skipped row per pass forever would be noise), each schedule run including the ones skipped inside their cadence window (which is what makes a missed run derivable), and each freshness verdict. The other phases — migration reconciliation, the two drains, provision reconcile, the reaps, the event drain — report through PlatformSweepReport only. Telemetry about a machine pass, not audited, and fire-and-forget: a recorder that throws must never sink the pass it is recording); issues (listIssues, setIssueStatus — operational failures grouped by fingerprint into failure classes, most recently seen first, with a staff lifecycle of resolve, ignore or reopen (#1290, #1293). No cursor by design: the grouping is the compression. regressed is ingest's word and the verdict refuses it); model usage (recordModelUsage, listModelUsage, summarizeModelUsage — meter 3's ledger, one line per model call a vertical made through the platform's model host, drained here as a model-usage intent. Idempotent on the intent id, so a retried drain records nothing twice, and retention-bounded by MODEL_USAGE_RETENTION_DAYS; the summary folds with the margin applied at read time and stores nothing, because metering is not billing (D-30)); and the logs (auditLog, accessLog, recordDelegatedRead, markAccessLogDrained, pruneAccessLog). recordDelegatedRead (#1470) is how the control plane leaves the K-24 row for a scope read it delegated to the hosted vertical holding the data — before it, the production path logged only a getScopeRecord entry for those reads, and the sentence above was true of the co-located branch alone. The row is the same shape whichever branch served it: the method comes from the closed delegatedReadMethod set (readScopeTable, listScopeTables, queryScope, listDenials, summarizeDenials, entityHistory, facetEvents, eventCause, eventEffects, invocationEvents, deadLetters), the adapter stamps id and at so a caller can neither backdate a row nor collide one, and a failed write fails the read — returning rows whose disclosure went unrecorded is the thing K-24 exists to prevent
ProvisionScopeInputinterfacetenant, scope, optional shape + jurisdiction (provisioned via provisionScope(actor, input))
globalFetch, FetchLikeconst, typeThe runtime's fetch, handed on as a value — the one place the FetchLike cast lives (#1291). Host code passes this, never the bare global in any spelling: workerd throws Illegal invocation when a connector calls an unbound fetch as input.fetch(…), and neither Node nor the workers test pool does, so no suite catches it. pnpm lint:bound-fetch refuses the bare global

Permission checker (permission-checker.ts)

The evaluation seam — the model is kernel-owned, the engine is swappable. See Permissions.

ExportPurpose
PermissionCheckercheck(principal, permission, node, entity?) → Promise<Decision>
assertAllowed(decision)throws PermissionDenied unless allowed; the standard first line of an operation. Narrows the type to the proof-carrying allow.
PermissionDeniedthe error class
denyAllCheckersecure default — denies everything
UNSAFE_allowAllCheckertest-only; grants everything via a synthetic proof tuple. The name is the warning.

ulid()

A dependency-free ULID generator — the ID scheme used everywhere (ids in @substrat-run/contracts).

ts
import { ulid } from '@substrat-run/kernel';
const id = ulid(); // '01JZX6ZH2E...'

The other seams

Everything below is in the kernel for the same reason: it is a rule two or more implementations have to agree on, and a second copy of it is a chance for them to disagree. All of it is web-standard only — globalThis.crypto, TextEncoder, URL — because a package that assumed Node would break the portability claim the adapters exist to prove.

Erasure and secrets

ModuleExportWhat it is
subject-keys.tscreateSubjectKeysThe mechanism under piiClass. A per-subject data key seals that subject's payloads at the moment a copy is written, so "erase from every copy we hold" becomes "destroy one key". A live scope needs no crypto — Tier 1 is mutable, erasing there is an ordinary redaction. The copies the platform keeps and cannot rewrite are the problem: a reap backup, a stored dump, the Tier-2 event lake. Those are full-fidelity on purpose, which is exactly why a DELETE can never reach one. A tombstone means a destroyed key is never re-minted
secret-box.tswebCryptoSecretBox, unconfiguredSecretBox, isSecretBoxConfigured, SecretBoxUnconfiguredErrorThe symmetric seal/open adapter the connection store rests on. The kernel decides that per-tenant credentials are encrypted at rest and that plaintext never touches the directory; what encrypts is swappable — Web Crypto locally, a Secrets Store binding or an external KMS when hosted. unconfiguredSecretBox refuses to store a credential rather than storing it in the clear
sealed-box.tssealTo, openSealed, generateSealingKeyPair, ConnectionSealingKeyUnavailableErrorThe asymmetric sibling, answering a question a scope cannot otherwise ask: hand this to a recipient I cannot talk to. Every path out of a hosted scope is a spine row, so a symmetric key minted in-scope would have to travel the same rows as the value it protects. A connection's public half can be projected down, and a public key is enough to write with — so the scope seals, and the connector opens at egress with a private half that never left the directory

Time, versions and reading the spine

ModuleExportWhat it is
clock.tsfrozenClock, manualClockThe test and replay clocks behind ctx.now() — the reason the instant is injected rather than read. frozenClock(at) answers one instant forever; manualClock(start) is a clock a test moves on purpose (read goes in as the host's clock, advance(ms) / set(at) move it), so "the hold has lapsed" is an exact assertion rather than a setTimeout. Shipped from the kernel, not a test package, for the same reason UNSAFE_allowAllChecker is: a vertical's own suite needs it without our tooling. See Testing with a clock
timeline.tsreadTimeline, readHistory, facetEvents, walkEventCause, walkEventEffects, readInvocation, readDeadLetters, TimelineReaderThe sanctioned reads of _substrat_outbox. For one entity: readTimeline is the envelope walk (id, type, when, who), readHistory adds the fat payload, the K-34 authorization, the K-42 impersonation stamp, the emitting operation (the invoke() string; null for a consumer's emit or a pre-column row), the version the emitting code was deployed as (read from the outbox column, never the envelope; null when no version identity was present) , causedBy (#1237: the id of the event whose delivery was in flight when this one was emitted — set for a consumer's or an executor's emit, null when an operation emitted it directly or the row predates the column) and invocationId (#1237: the call the event belongs to, which is what readInvocation groups on — null when no call carried one: a seed, an internal call, or a row written before the column) — null payload after a shred is a supported answer. For the scope: facetEvents (#1404) narrows by type and window, groups by one envelope column or one payload field and counts, with erased payloads counted in their own total rather than folded into a "no value" bucket; walkEventCause (#1445) follows causedBy backwards from one event and ends in a named terminaloperation is a complete chain, unrecorded a truncated one, depth, missing and cycle the ways the walk gave up — so a fragment is never presented as the whole story; walkEventEffects (#1455) is the mirror, forwards: the consumers an event reached and what they emitted in turn, a tree of recorded steps with real timestamps rather than a timing waterfall; readInvocation (#1237) reads by the call instead of by cause — every event one invocation emitted, oldest first, including what its consumers emitted in the same tail, so two events an operation raised side by side (invisible to both walks) come back together, with truncated saying the call did more than is shown; readDeadLetters (#1525) lists every delivery that gave up — error set and no retry due, since a retrying row carries an error too — joined to its event's type, entity, time and call, newest event first and keyset-paged on the (event, consumer) pair so an event that failed on several consumers is not split by a page boundary. All build a SELECT and nothing else, and none checks permission: the caller's assertAllowed comes first, on the line above. The per-entity reads page like an HTTP list (ListPage in, Page out)
entity-version.tsentityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEXWhat ctx.versionOf and the If-Match check are made of: an entity's version is the ULID of the last outbox row about it — no version column anywhere. Both adapters run the one query here so they cannot answer differently; assertIfMatch turns a stale tag into precondition_failed
idempotency.tsIDEMPOTENCY_DDL, assertIdempotencyKey, idempotencyLookupQuery, replayFor, idempotencyRecordStatement, idempotencyPruneStatement, idempotencySubjectThe remembering half of Idempotency-Key: the _substrat_idempotency spine table, the lookup that decides replay from reuse by fingerprint, and the prune that keeps it a cache rather than an archive. The wire half — header names, key validity, the fingerprint — is in @substrat-run/contracts
impersonation.tsIMPERSONATION_DDL, newImpersonationSession, assertSessionUsable, assertImpersonationWrites, impersonationStampOf, ImpersonationRefused, …The session record behind beginImpersonation / getImpersonatedScope (K-42): the directory's _substrat_impersonations table, the check every invoke re-runs (not ended, not expired, same tenant and scope), the refusal a read-only session gives the effecting verbs, and the { session, actor } stamp the outbox, the denial log and the platform-intent journal all carry

Trusting the edges

ModuleExportWhat it is
routed-node.tsreadRoutedNode, RouterAssertionErrorThe vertical's side of the router contract: read the (tenant, scope, surface) the router asserted over its service binding. A request with no assertion is legitimate — that is a standalone deploy
platform-call.tsassertPlatformCall, PLATFORM_SECRET_HEADER, …The opposite direction: is the platform itself calling? Provisioning is control-plane-driven — only the vertical can create a usable scope DO — and here there is no legitimate unauthenticated case, because an open provisioning endpoint lets a stranger mint tenants inside your vertical. So this one fails closed with no configuration at all
read-only-sql.tsassertReadOnlyQueryThe textual gate in front of the scope SQL console. readScopeTable is safe by construction; a console taking user SQL is not, so read-only-ness is enforced per statement in two layers — this shared scan (both adapters, same rejections, so a query that runs in dev runs in prod) and an adapter-authoritative backstop behind it. The scan reads bare tokens outside comments, string literals and quoted identifiers, so a ; inside a string never trips it
invocation-log.tsinvocationLog, InvocationLogLine, InvocationLogContextOne structured log line per invocation, stamped with the tenant and scope the router asserted — the two dimensions Cloudflare cannot record, because observability is keyed on the script and one vertical's script serves every tenant that installed it. A successful request otherwise emits no log event at all, so this line is what gives a tenant-facing log view any rows. The stamp is written from readRoutedNode's verified answer, never from the header: a forged tenant would file chosen text on somebody else's dashboard, and an un-routed local invocation writes nothing. Mounted app.use('*', invocationLog({ routerSecret })) first on a deployable vertical's Hono app; pnpm lint:invocation-log refuses a missing, late or secretless mount (#1418)

Fleet arithmetic

These are pure functions over the directory projection, deliberately shared so two callers can never disagree about what a number means.

ModuleExportWhat it is
platform-sweep.tsrunPlatformSweep, startPlatformSweeper, EventSink, AccessLogSink, PlatformSweepOptions, PlatformSweepReportOne pass of every scheduled thing the platform does, in a fixed order: migration reconciliation first (§5.3, #49), then drain retryable effects, drain pending platform intents, re-run the provision hook on every primary scope whose bound version is not the one its provision receipt names — a null receipt counts as behind, since a scope provisioned before the receipt existed has no evidence either way (#1172), run each vertical's declared schedules (#383), judge each module's declared freshness expectations (#1272), reap expired snapshots and previews, reap long-archived scopes, reap lapsed tenants, reconcile connectors (each live connection through its provider's sweeper), drain each scope's domain events to Tier 2 through the EventSink (#1413), and the staff access log through the AccessLogSink last, pruning what shipped. Migration goes first because a drain wakes a scope and wake migrates lazily — running it later would count every failed scope twice — and a scope whose migration failed this pass is skipped by the five phases that would run its code (both drains, provision reconcile, schedules, freshness), since a fail-closed scope would only re-throw the same error; the reaps, the connector pass and the event drain are not keyed on that set. The two sinks are injected, and absent a sink nothing drains — a self-host with none configured is a supported posture, it just keeps everything. recordSweepRun is likewise optional: when it is set, the connector, schedule and freshness phases write one _substrat_sweep_runs row per unit (#1255, the recorded subset the HostAdmin row above spells out); the pass returns a PlatformSweepReport per phase whether or not it is. It holds no timer: a node deployment calls startPlatformSweeper, a Cloudflare one arms a sweeper DO's alarm
migration-progress.tsmigrationFleet, migrationProgress, migrationSummary, MIGRATION_FLAG_THRESHOLDWhat "487/500 migrated, 13 pending, 0 failed" means, computed once for both the sweep's report and the ops view. It reads the directory projection against the registered frontier — no scope is woken to answer a fleet question
meters.tsfoldMeterReadingWhat counts as billable. Every adapter has the same three directory tables and could each write the same GROUP BY; they must not, because the billable rule is a commercial definition and two copies in two dialects is how the two fleets end up quoting different numbers for the same month
model-usage.tsfoldModelUsage, MODEL_USAGE_RETENTION_DAYSMeter 3's number, defined once for exactly the reason foldMeterReading is. Adapters list the lines in a window; this folds them, per (tenant, vertical, model) and in total: list price summed with addDecimal — an adapter summing in SQL would float the money — the platform's margin applied at read time rather than stored (D-30: meter, don't bill), and a call the rate card could not price counted as unpriced beside the money rather than folded in as $0, so an unpriced model reads as a gap in pricing instead of a free call
provider-error.tsisTerminalProviderError, providerErrorStatus, RETRYABLE_CLIENT_STATUSESIs a failed outbound call worth trying again? Deliberately structural — any error carrying a numeric status — so the drain never imports a provider's error class to classify it. It exists because a provider answering 409 requires valid personal number field was once retried a hundred times over two days: that is not a fault to wait out, it is the provider telling the caller its request is wrong, and attempt 101 carries identical bytes
scope-record.tsresolveScopeRecordThe directory row provisionScope writes, with every optional resolved — in the kernel so the two adapters cannot default differently

Guarantees adapters must uphold

Any ScopeHost implementation must provide — verified by @substrat-run/contract-tests:

  • Strict serialization per scope — one operation at a time, to completion.
  • Structured-clone boundary — inputs/results cloned both directions, even in-process.
  • Kernel-stamped events — id, timestamp, tenant, scope, actor stamped below the API surface.
  • Fail-closed addressing — mismatched (tenantId, scopeId) throws, never resolves elsewhere.
  • PII invariant at emit — PII-classed events without subjectId are rejected.
  • An entity's version is the last event's ULIDversionOf moves on every event about the entity, never moves for an event about another, and survives a shred: the payload is erased and the envelope kept, so an erased entity can still refuse a stale write. There is no version column anywhere, and deliberately not going to be one.

The hard parts, hosted.