Skip to content

Architecture

Substrat is one decomposition seen two ways: a stack of layers (what the code is) and a request path across isolated databases (how it runs). Both pictures are below.

The three layers

Everything hangs off one split — humans and hard guarantees below the line, AI velocity above it. Every band is a real, shipping package.

Verticals

Everything a user touches — the businesses themselves.

own vocabulary · screens · pricing · roles

Calloutfield serviceHandlebarbike workshopKallkällacoffee shopMeridianHRManyfoldheadless CMS
the line
Above ↑ AI velocity — mistakes are cosmetic (a wrong screen)↓ Below humans + runtime guarantees — mistakes are catastrophic (a tenant leak)

composes engines in-scope, same transaction

Engines

Headless domain machinery that owns invariants. Star topology — they talk to the kernel, never to each other.

own invariants · versioned · never forked

workorderone state machine · append-only time + materialbookingresource × interval × capacity · one allocation, no locksinvoicingconsumes billable events · immutable once exportedprotocolchecklists + signed docs · freeze → immutable, hashedinviteshashed identifier · accept-required · non-enumerable

ctx (sql · check · emit · link)  ·  events + audit

Kernel

The substrate. Everything true of every B2B SaaS — and nothing true of any one.

owns no domain entities

IdentityNested tenancyPermissions + grantsEvents / audit spineMigrationsGDPR machineryNotificationsJobsBilling entitlementsModule systemAttachment contractsApp shell
Every operation runs insidectx.sqlctx.checkctx.emitctx.link

No customer table, no work-order table. It offers attachment contracts that bind to opaque (entityType, entityId) refs the vertical defines.

same kernel semantics on any ground — one contract-test suite gates them all

Adapters

Scope hosts — the interchangeable ground the kernel is seated on.

swappable · escrowable · self-hostable

adapter-sqlitedev · CI · self-host / escrowadapter-cloudflareproduction · Durable-Object per scopeadapter-emailnotification transport · CF Email + mock

Connectors

The outside world, at the edges. React to events on the spine — host code, never module code.

no fetch inside a module — ever

Scrive eSignsignatures-requested → BankID signing → recorded back…moreone port per capability

The four rules that hold it together

  1. 1Kernel owns no domain entities. It provides the spine; verticals define what the entities mean.
  2. 2Star topology. Engines cooperate through fat events and opaque refs — never by importing each other. N contracts, not N².
  3. 3Enforced at runtime. Guarantees are defaults of the substrate, not config a builder — human or AI — can get wrong.
  4. 4No forking. If a vertical ever needs to fork an engine, the engine drew its line wrong.

Topology

your codeVerticalTypeScript · often AI-built@substrat-run/kernel API onlykernelScope hostgetScope(principal, tenant, scope)holding a stub is the authorizationone isolation domainScope · branch #1its own database + ACLserialized — one op at a timeone isolation domainScope · branch #240its own database + ACLserialized — one op at a timeevents, kernel-stampedkernel-ownedEvent spineaudit · reporting · integrations
The same shape on every adapter — one SQLite file per scope locally, one Durable Object per scope in production. Nothing above the stub can widen its own reach.

The invariants this picture encodes:

  • The only data path from vertical code to operational data is the scope stub. Holding a stub is the authorization to talk to that scope — and the scope still re-validates every call against its own ACL.
  • Events are emitted below the API surface. Vertical code cannot emit on another scope's behalf, suppress an audit event, or edit its envelope.
  • Ambient tenancy. After obtaining a stub, vertical code never passes tenant or scope IDs again — context rides inside the stub and the operation context. There is no ID parameter to get wrong.

The scope: unit of isolation and consistency

A scope is one isolation domain — a housing association, a branch office, a client company, a brand. Each scope has:

  • its own database (one SQLite file locally; a SQLite-backed Durable Object on the Cloudflare adapter),
  • strict serialization — one operation at a time, run to completion. No interleaved read-modify-writes, no lost updates, no need for row locking in module code,
  • a structured-clone boundary — inputs and results are cloned even in-process, so code can never share mutable state with a scope.

Operations run inside the scope's execution domain: one hop to reach the scope, then local, synchronous SQL. This is what makes engine invariants enforceable — the handler sees sql, emit, and check; the caller sees only invoke().

See Tenants & scopes and Operations & the scope host.

Contracts first, adapters below

Every boundary-crossing data shape is a Zod schema in @substrat-run/contracts — the reviewed artifact is the runtime validator ("parse, don't trust"). The kernel's behavioral seams are pure TypeScript interfaces in @substrat-run/kernel that import no platform APIs.

Platform specifics live only in adapters:

AdapterBackingUse
@substrat-run/adapter-sqliteone SQLite file per scope, per-scope actorlocal dev, CI, self-host
@substrat-run/adapter-cloudflareSQLite-backed Durable Object per scope + a durable control-plane DOproduction

The rule is testable and non-negotiable: a module's contract tests must pass unchanged on both adapters — and they do: the shared conformance suite runs green on the pure-SQLite adapter in Node and on the Cloudflare adapter in real workerd against Durable Objects. Neither is a mock; both implement the same semantics (serialization, clone boundary, fail-closed addressing, stamped envelopes). This is what makes local development deterministic, CI cloud-free, and the self-host/escrow story literally true — and it's how a vertical moves from laptop to Cloudflare with no code change.

The hosted runtime

The topology above is adapter-neutral. Here is what it becomes on the Cloudflare adapter — the production shape. The one thing to hold onto: every box is a Durable Object with its own SQLite. There is no shared cluster; a tenant's data sits in its own isolated database, and the router's only job is to find the right door.

How a request travels

response · metered at the routerBrowseracme.callout.substrat.runthe requestcloudflare edgeRouterOne per environment.Finds the door; cannot open it.kernel onlyhostname → nodedurable objectControl-plane DOThe directory.One per environment.kernel onlydispatch · asserts the node + ROUTER_SECRETworker · one per versionThe vertical workerYour pushed bundle. No public route, no state between requests.Kernel hostgetScope() · permission gate · meteringVertical HTTProutes, error envelope, session → principalConnector codethe only place fetch() is allowed to existsession → principalgetScope() · invoke()durable objectIdentity DOOne per tenant.Users, sessions, the owner seat.kernel onlydurable objectScope DOIts own SQLite. One operation at a time.Vertical operations — ctx.check(), then ctx.sqlEngine functions — the same transactionKernel spine — events, outbox, links, migrations
One bundle, two execution environments. The worker is trusted with addressing and never with data; the scope holds the data and cannot reach the network.
1

Browser hits a hostname edge

A tenant, a vertical, and a surface — all encoded in the name.

acme.callout.substrat.run
2

The router resolves the door kernel worker · 1 per env

One kernel-owned worker in front of every vertical. Its only binding is the control plane — it reads the directory to turn hostname → (tenant, scope, vertical, surface). It finds the door; it cannot open a scope even by mistake.

reads → Control-plane DB
3

Header handshake, then dispatch compute

Every client x-substrat-* header is stripped; the router asserts the resolved node plus a shared secret, then dispatches to the vertical worker. The vertical has no public route — the router is the only way in.

4

Vertical worker resolves who you are compute

The vertical worker holds no state between requests. It resolves your session to a principal in the tenant’s own identity database.

reads → Identity DB (this tenant)
5

Open the scope, run the operation compute

Gate the scope’s lifecycle & tenancy, then invoke() runs the operation inside the scope’s own SQLite, in one transaction — permission check first, mutation emits an event, the outbox drains to consumers and connectors. Roll back on any throw.

reads + writes → Scope DB (this scope)
6

Response travels back up edge

Back through the router, the one place that knows the tenant — so it meters the request there, one datapoint per call.

The three databases

Directory

Control-plane DB

one per environment
  • Tenant registry & scope lifecycle
  • Roles, tenant grants, entitlements
  • Hostnames, verticals & versions
  • Connections (ciphertext only)
  • The admin audit log

Knows which door — never what’s behind it. A single singleton DO.

Application / auth

Identity DB

one per tenant
  • Users, sessions, credentials
  • Its own auth engine, own SQLite
  • The login → principal map
  • The owner seat, set at provision

Separate DO, separate storage — one tenant’s users can’t leak to another.

Business data

Scope DB

one per scope
  • The vertical’s entities & kernel spine
  • Events, outbox, entity links
  • Applied migrations
  • Scope-level grants & permissions

Where ctx.sql runs — one transaction per operation.

How the databases get created

The trick: a Durable Object’s database springs into existence the first time you address it by id. There is no CREATE DATABASE and no migration server — provisioning is just addressing a new DO and letting it build itself.

1

Write the directory row. The coordinator records the new scope in the control plane — the door now exists, gated by the tenant.

2

Address the Scope DO. The moment it’s named, its SQLite is born. A lazy migration builds the kernel spine and runs the vertical’s own module migrations in order — a PITR bookmark taken before each pass.

3

Project permissions in. The tenant’s current roles and grants are copied into the fresh scope so it can decide access from its own storage — then the migration frontier is recorded back to the directory.

4

Identity DB, likewise. The tenant’s Identity DO is created on first address — tables on construction, the owner seat set at provision, waiting to be claimed by the first login.

Why the shared control plane isn’t a shared blast radius

A normal vertical runs “CP-less” on the hot path: it decides permissions from the scope’s own storage and trusts the node the router asserted — the shared control plane is off the request path entirely. It still owns provisioning and the audit spine, but a request serving one tenant never touches another tenant’s data, or the shared directory, to answer.

The result: the same kernel guarantees, a per-tenant database, and a shared control plane whose failure can’t read or corrupt a running scope. Isolation is the default, not a configuration you can forget.

Modules: how everything joins

Engines and verticals join a host the same way — as modules. A module registration bundles:

  • a manifest — self-describing metadata: permissions (with human-readable descriptions), events emitted and consumed, migrations, attachment targets, entity relations, an entitlement key, and optional UI contributions;
  • migrations — plain SQL, journaled per module, applied lazily per scope inside the scope's serialization domain;
  • operations — named handlers ('workorder/create') invoked through scope stubs;
  • event consumers — handlers for event types other modules emit;
  • schedules — recurring work, fired by the deployment's own sweeper under a system actor rather than by a cron the module holds.

A vertical increasingly does not write most of that. It declares its entities, operations and permissions once in the model, and the manifest's entity fragments, permission list, event list and DDL are derived from that declaration — with CI failing on drift between the declaration and what is checked in.

See Modules & the manifest.

Composition: star topology

Engines talk to the kernel, never to each other. No engine imports or calls a sibling. Composition happens through three kernel-mediated channels:

  1. Opaque refs — attachment contracts bind to (entityType, entityId) without knowing what the entity is.
  2. Events — an engine reacts to another's schema-versioned events. A contract, not a call: the invoicing engine consumes workorder.completed andcommerce.order-placed — events from two different domains — without importing a single type from either producer.
  3. Vertical-owned orchestration — synchronous flows that need two engines are wired in the vertical, where the glue is visible and editable.

This keeps compatibility at N kernel contracts instead of engine pairs, and keeps each engine independently versioned. The corollary test: if two engines need chatty synchronous communication, they are one engine drawn wrong — which is why "work orders + time reporting" is one engine, not two.

Language: TypeScript end-to-end

Verticals are TypeScript regardless of what the kernel is written in — React UIs, prompt-to-app tools, and coding agents all emit it. Keeping the kernel in TypeScript means one source of truth at the most important interface in the system: "invalid states unrepresentable" materializes directly at the SDK boundary (branded ID types, discriminated unions, literal types) rather than through generated bindings.

Types erase at runtime, so they are never the enforcement: every trust boundary validates at runtime with the same Zod schemas, and the guarantees that matter are structural — the scope boundary, capability stubs, kernel-side stamping — not type-level. Types are ergonomics, especially for agents; the enforcement doesn't depend on the compiler.

The hard parts, hosted.