@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.
pnpm add @substrat-run/kernel @substrat-run/contractsScope host (scope-host.ts)
The adapter seam. Full semantics in Operations & the scope host.
| Export | Kind | Purpose |
|---|---|---|
ScopeHost | interface | getScope, 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 |
ScopeStub | interface | the capability — the only way code outside a scope reaches it |
OperationContext | interface | what 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> |
ConsumerHandler | type | event consumer; at-least-once, must be idempotent |
ModuleRegistration | interface | manifest + 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) |
SqlMigration | interface | { version, sql } — ordered, journaled per module |
ScopedSql, SqlValue | types | synchronous scope-local SQL: query<T>(), exec() |
ExecutorHandler | type | (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 |
HostAdmin | interface | the 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 |
ProvisionScopeInput | interface | tenant, scope, optional shape + jurisdiction (provisioned via provisionScope(actor, input)) |
globalFetch, FetchLike | const, type | The 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.
| Export | Purpose |
|---|---|
PermissionChecker | check(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. |
PermissionDenied | the error class |
denyAllChecker | secure default — denies everything |
UNSAFE_allowAllChecker | test-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).
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
| Module | Export | What it is |
|---|---|---|
subject-keys.ts | createSubjectKeys | The 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.ts | webCryptoSecretBox, unconfiguredSecretBox, isSecretBoxConfigured, SecretBoxUnconfiguredError | The 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.ts | sealTo, openSealed, generateSealingKeyPair, ConnectionSealingKeyUnavailableError | The 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
| Module | Export | What it is |
|---|---|---|
clock.ts | frozenClock, manualClock | The 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.ts | readTimeline, readHistory, facetEvents, walkEventCause, walkEventEffects, readInvocation, readDeadLetters, TimelineReader | The 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 terminal — operation 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.ts | entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX | What 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.ts | IDEMPOTENCY_DDL, assertIdempotencyKey, idempotencyLookupQuery, replayFor, idempotencyRecordStatement, idempotencyPruneStatement, idempotencySubject | The 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.ts | IMPERSONATION_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
| Module | Export | What it is |
|---|---|---|
routed-node.ts | readRoutedNode, RouterAssertionError | The 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.ts | assertPlatformCall, 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.ts | assertReadOnlyQuery | The 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.ts | invocationLog, InvocationLogLine, InvocationLogContext | One 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.
| Module | Export | What it is |
|---|---|---|
platform-sweep.ts | runPlatformSweep, startPlatformSweeper, EventSink, AccessLogSink, PlatformSweepOptions, PlatformSweepReport | One 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.ts | migrationFleet, migrationProgress, migrationSummary, MIGRATION_FLAG_THRESHOLD | What "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.ts | foldMeterReading | What 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.ts | foldModelUsage, MODEL_USAGE_RETENTION_DAYS | Meter 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.ts | isTerminalProviderError, providerErrorStatus, RETRYABLE_CLIENT_STATUSES | Is 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.ts | resolveScopeRecord | The 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
subjectIdare rejected. - An entity's version is the last event's ULID —
versionOfmoves 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.