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
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
↓ 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
ctx.sqlctx.checkctx.emitctx.linkNo 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
Connectors
The outside world, at the edges. React to events on the spine — host code, never module code.
no fetch inside a module — ever
The four rules that hold it together
- 1Kernel owns no domain entities. It provides the spine; verticals define what the entities mean.
- 2Star topology. Engines cooperate through fat events and opaque refs — never by importing each other. N contracts, not N².
- 3Enforced at runtime. Guarantees are defaults of the substrate, not config a builder — human or AI — can get wrong.
- 4No forking. If a vertical ever needs to fork an engine, the engine drew its line wrong.
Topology
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:
| Adapter | Backing | Use |
|---|---|---|
@substrat-run/adapter-sqlite | one SQLite file per scope, per-scope actor | local dev, CI, self-host |
@substrat-run/adapter-cloudflare | SQLite-backed Durable Object per scope + a durable control-plane DO | production |
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
Browser hits a hostname edge
A tenant, a vertical, and a surface — all encoded in the name.
acme.callout.substrat.runThe 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 DBHeader 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.
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)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)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.
Write the directory row. The coordinator records the new scope in the control plane — the door now exists, gated by the tenant.
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.
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.
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.
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:
- Opaque refs — attachment contracts bind to
(entityType, entityId)without knowing what the entity is. - Events — an engine reacts to another's schema-versioned events. A contract, not a call: the invoicing engine consumes
workorder.completedandcommerce.order-placed— events from two different domains — without importing a single type from either producer. - 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 N² 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.