# Substrat — complete documentation > The hard parts, hosted. A runtime-enforced substrate for building vertical B2B SaaS. Describes **@substrat-run/kernel 0.115.0**. Index: https://substrat.net/llms.txt --- # How to read this Source: https://substrat.net/book/index.md This is the book. The rest of these docs are a reference — you arrive with a question, find the page that answers it, and leave. That works well once you know the shape of the system, and badly before. Nothing in a reference tells you how the pieces *join*: which thing calls which, what happens between the moment a request lands and the moment a row is written, who retries what when it fails. So this section is the other thing. Thirteen chapters, meant to be read in order, front to back, in a long afternoon. It assumes nothing except that you have written server software before. It repeats very little — where a reference page already says a thing well, this book links to it and moves on. ## What you will know at the end - What the words mean: tenant, scope, module, engine, vertical, the spine, consumer, executor, connector, platform intent. - What a scope is, why every one of them is its own database, and what that buys. - The complete path of one HTTP request, from hostname to SQL and back, with every hop named. - What a handler can reach, what it cannot, and why each ban exists. Also why plain SQL with no query builder is safe enough, why timestamps are text, and what a slow `await` blocks. - How an event gets from `ctx.emit` to a consumer in another module, what happens when the consumer throws, and which failures retry and which do not. Also how a platform intent gets run in seconds instead of at the next sweep. - How permission checks are answered without a network call. - Why engines never call each other, and what they do instead. - What `substrat push` actually does, and what happens to data across a version change. - What the two background clocks are, what each sweeps, and why both are alarms rather than crons. - What the platform records about a running app, from sampled traffic to exact event history, and how a record joins to the request that changed it. - Which log is the audit log for which question, and how events leave their scope for a lake you can query with SQL. - How a vertical meters and bills its own customers, how the platform meters a tenant, and what is not counted yet. - What breaks in production, where you look, and what the lifecycle states mean. ## How to read it Straight through. Each chapter ends where the next begins, and later chapters lean on earlier ones — chapter 5 assumes chapter 4's picture of a handler, chapter 9 assumes chapter 5's outbox, and chapters 10 to 12 assume both. If a word stops you, the glossary is at the end of chapter 1. The **Next** link at the foot of every page is the intended path. Two chapters are worth reading even out of order, because they are the ones the reference genuinely does not cover anywhere: [The life of one event](https://substrat.net/book/05-one-event.md) and [The two clocks](https://substrat.net/book/09-the-two-clocks.md). ## Read it in one file The whole book is also published as a single document, three ways: - **[book.epub](https://substrat.net/book.epub)** — a real EPUB 3, with a cover and a table of contents. The link downloads the file; open it from there in Apple Books, Kobo, or anything else that reads EPUB, and it remembers where you got to and lets you set the type the way you like it. On a phone that is one step more than tapping a link — see below. - **[book.txt](https://substrat.net/book.txt)** — every chapter concatenated, plain markdown. Good for printing, for `pandoc`, or for handing to a model in one shot. - **[book/read.html](https://substrat.net/book/read.html)** — the same thing as one scrolling, printable web page. All three are generated from these chapters at build time, so they cannot fall behind. > **Tip — Reading it on a phone** > > There is no URL that opens Books directly — iOS decides that from the file itself. Tapping > **book.epub** in Safari downloads it; opening it from Files then offers Books, which syncs > it to your other devices. On a Mac, double-clicking the download does the same thing in one > step. If you want the *reference* in one file instead — every page on this site, not just the book — that is [llms-full.txt](https://substrat.net/llms-full.txt), and the index agents read first is [llms.txt](https://substrat.net/llms.txt). ## What changed in this edition The book first shipped with ten chapters. This edition adds three: [Seeing what happened](https://substrat.net/book/10-seeing-what-happened.md), [The audit trail and the lake](https://substrat.net/book/11-audit-and-the-lake.md) and [Metering and billing](https://substrat.net/book/12-metering-and-billing.md). *Operating it* is now chapter 13. It also answers questions readers of the first edition asked. Chapter 1 now has a glossary. Chapter 4 explains the absence of a query builder, the text timestamps, and what an `await` holds. Chapter 5 explains the kick that makes platform intents fast. And the chapters now reflect what shipped since: invocation ids and the event walks, the MCP surface, the lake drain, the dashboard's four tabs, and the new reference verticals. ## A note on honesty Substrat is 0.x. Several things in this book are described as built because they are, and a few are described as not built because they are not. Where a mechanism has a known gap, the chapter says so in the place you would otherwise assume it was covered, rather than in a footnote. [What Substrat doesn't have (yet)](https://substrat.net/guide/what-substrat-lacks.md) is the concentrated version of that. --- # 1. Why a substrate at all Source: https://substrat.net/book/01-why-a-substrate.md Every piece of business software for a specific trade — a field-service app, a workshop system, a clinic's booking tool — is mostly the same software. Not in the part anyone pays for, which is the vocabulary and the screens and the pricing. In the part underneath: tenancy, roles, permission checks, an audit trail, migrations, deploys, backups, an invite flow, a way to run a copy of production without touching production. That underneath is perhaps 80% of the code and nearly all of the risk. It is also the part that is wrong in almost every such system, in the same few ways, and the ways are not subtle: - **A missing `WHERE tenant_id = ?`.** One forgotten clause, and a query reads another customer's rows. The type checker cannot see it, the tests do not have two tenants in them, and the failure is silent until it is a disclosure. - **A permission check that was never written.** The handler that lists is guarded; the handler that exports is not, because it was added in a hurry six months later. - **An audit trail that is a `log.info` call.** It is complete exactly as long as everyone remembers to write one, which is to say it is not complete. - **State machines enforced by convention.** A row goes from `draft` to `invoiced` because every code path happens to move it that way — until one does not. Each of these is an instance of the same thing: an invariant the system *depends on*, enforced by a person remembering. The remedy is not better discipline. It is moving the invariant somewhere discipline is not required. ## The bet Substrat's bet is that this substrate is worth building once, properly, and that "properly" means **the runtime refuses**, not that the docs advise. So: a kernel owns tenancy, permissions, events, migrations and the transaction boundary, and it owns them in a way module code cannot opt out of. There is no `WHERE tenant_id` to forget, because a handler's `ctx.sql` is already inside one tenant's database and has no syntax for reaching another. There is no un-audited mutation, because the event is written in the same transaction as the row and the envelope is stamped by the kernel, not supplied by the caller. There is no unchecked operation, because a check that was never called leaves a trail of its own. That is the claim [Why runtime enforcement?](https://substrat.net/guide/why-substrat.md) argues at length. This book takes it as given and shows the machinery. ## The three layers **Diagram — the three-layer stack.** Six bands, top to bottom: verticals, the line, engines, the kernel, the adapters under it, and connectors at the edge. - **Verticals** — Everything a user touches — the businesses themselves. *(own vocabulary · screens · pricing · roles)* - `Callout` — field service - `Handlebar` — bike workshop - `Kallkälla` — coffee shop - `Meridian` — HR - `Manyfold` — headless CMS - **The line.** Above: AI velocity — mistakes are cosmetic (a wrong screen). Below: humans + runtime guarantees — mistakes are catastrophic (a tenant leak). - Verticals meet engines by: 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)* - `workorder` — one state machine · append-only time + material - `booking` — resource × interval × capacity · one allocation, no locks - `invoicing` — consumes billable events · immutable once exported - `protocol` — checklists + signed docs · freeze → immutable, hashed - `invites` — hashed identifier · accept-required · non-enumerable - Engines meet the kernel by: ↓ 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)* - Provides: Identity, Nested tenancy, Permissions + grants, Events / audit spine, Migrations, GDPR machinery, Notifications, Jobs, Billing entitlements, Module system, Attachment contracts, App shell. - Every operation runs inside: `ctx.sql`, `ctx.check`, `ctx.emit`, `ctx.link`. - No customer table, no work-order table. It offers attachment contracts that bind to opaque (entityType, entityId) refs the vertical defines. - Kernel meets the adapters by: 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-sqlite` — dev · CI · self-host / escrow - `adapter-cloudflare` — production · Durable-Object per scope - `adapter-email` — notification 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 eSign` — signatures-requested → BankID signing → recorded back - `…more` — one port per capability **The four rules that hold it together** 1. **Kernel owns no domain entities.** It provides the spine; verticals define what the entities mean. 2. **Star topology.** Engines cooperate through fat events and opaque refs — never by importing each other. N contracts, not N². 3. **Enforced at runtime.** Guarantees are defaults of the substrate, not config a builder — human or AI — can get wrong. 4. **No forking.** If a vertical ever needs to fork an engine, the engine drew its line wrong. Read it bottom-up. **The kernel** owns what must never be wrong. It provides an `OperationContext` — `sql`, `emit`, `check`, `link`, `grant`, `now` — and nothing else. It knows no domain entities at all: it has never heard of a work order or an invoice, and could not be made to special-case one. **Engines** own invariants inside a domain. The work-order engine knows that a completed order cannot go back to scheduled, that every state change emits an event, that an exported billing basis is immutable afterwards. There are seven of them, each headless: no screens, no vocabulary, no opinion about what you call things. **Verticals** own everything a user touches: the words, the roles, the prices, the screens, the workflow. A vertical composes engines the way an application composes libraries, except that the library's invariants are enforced from below rather than trusted. The line between the bottom two bands and the top one is the interesting one, and it is drawn where it is on purpose. Below it, change is slow, reviewed, and versioned, because being wrong there is expensive. Above it, change is fast — a vertical is exactly the kind of code an agent or a small team can write quickly, because the expensive mistakes are not available to make. You cannot skip a state. You cannot forget a check and have nothing notice. You cannot read another tenant. That is the actual product: not "AI writes your app", but *a place where AI writing your app is not reckless*. [Where AI mistakes stop](https://substrat.net/guide/ai-guardrails.md) is that argument with the specific failure modes named. ## What this costs you It is worth being blunt about the shape of the trade, because it is a real one. **You give up arbitrary data access.** A handler reads and writes through `ctx.sql`, inside one scope, and nothing else. No connection pool, no second database, no `fetch` to a sibling service. If your design needs a handler to read across tenants, the design has to change; the runtime will not bend. **You give up cross-module joins.** Another module's tables are private — not "please don't", but a linted, reviewable boundary. A vertical that wants extra fields on an engine's entity keeps its own side table keyed by the engine's id, and never a column upstream. **You give up synchronous everything.** Effects that are not scope-local — sending mail, calling a provider, writing tenant-wide directory state — do not happen inline inside your transaction. They are asked for, and effected out of band. Chapter 5 is largely about this. In exchange, a class of bug becomes structurally unavailable rather than merely rare, and you get the operational half — audit, snapshots, previews, migrations, backout — as properties of the substrate rather than as projects. ## What is not in the box Substrat is not a BaaS, not an ORM, and not a low-code builder. It does not generate your UI, though it will generate your API client. It has opinions about tenancy and none about CSS. It is also not finished. [What Substrat doesn't have (yet)](https://substrat.net/guide/what-substrat-lacks.md) is the current list, and this book flags the relevant gaps where they arise rather than saving them up. ## The words this book uses A handful of terms carry most of the weight from here on. Each one gets its full chapter later, but none of them should be used before it is defined, so here they are in one place. **Tenant.** The business that pays. It is the billing and identity boundary. **Scope.** One isolation domain inside a tenant, such as a branch, a client or a property. Each scope is its own database (chapter 2). An installed app is a scope running a vertical. **Module.** The unit of code the kernel loads into a scope. A module is one registration: a **manifest** (its id, the permission keys it declares, the events it emits and consumes, its schedules), plus its SQL migrations, its operation handlers, their input schemas, and its event consumers. **Every engine is a module, and so is every vertical.** A deployed app registers its own vertical module next to the engine modules it composes, in a fixed order, because that order is also the order migrations run in. **Operation.** A named, permission-checked, transactional entry point into a module, invoked as `module/name`, such as `workorder/create`. The only way anything changes a scope's data. **Engine.** A module that owns invariants in one domain, such as work orders, invoicing or booking, and has no screens and no vocabulary. **Vertical.** A module that owns everything a user touches in one trade: the words, roles, prices and workflow. It composes engines. It also ships the worker around the module: HTTP routes, authentication and the app. **Harness.** Code in the vertical's worker that is *not* module code: the server, the seed, the routes, the tests. The rules in chapter 4 bind module code, and harness code is where the exempt work, such as calling a model, happens. **The spine.** The kernel's own tables, all named `_substrat_*`, inside every scope and in the directory: the event outbox, the delivery journal, the denial log, the permission tuples, the platform-intent queue, the admin log. Module code may read the spine and may never write it. It is the record the rest of the system trusts. **Event.** A kernel-stamped fact that a mutation happened, written to the **outbox** in the same transaction as the mutation (chapter 5). **Consumer.** A module's handler for another module's event. It runs inside the same scope, in its own transaction, as a system actor. **Executor.** A handler the *host* registers for an event, which runs outside the scope with platform authority, for effects one scope cannot make on its own. **Connector** is the executor you will meet most: one that is also given a credential and permission to call exactly one outside provider. **Platform intent.** A request, written by an operation into the spine, for the platform to do something privileged on the scope's behalf, such as provisioning another scope. The platform executes it later and writes the outcome back. **Control plane, directory.** The platform's own service, and the database it keeps: which tenants and scopes exist, where they route, who holds which role, and the admin log. ## Two pictures, one system There are two ways to look at what follows, and you need both. The layer stack above is *what the code is* — which package owns which concern, which direction dependencies point. It is the picture you need when deciding where a change belongs. The other picture is *how it runs*: a request arriving at a hostname, resolving to a tenant and a scope, reaching a database that holds that scope's data and no one else's. That is the next chapter, and it is the one that makes the rest of the book legible. --- **Next:** [Tenants, scopes, and one database each →](https://substrat.net/book/02-tenants-and-scopes.md) --- # 2. Tenants, scopes, and one database each Source: https://substrat.net/book/02-tenants-and-scopes.md Everything in Substrat hangs off one structural decision, and it is the one to understand first, because every later mechanism is shaped by it. ## Two levels, tree-shaped A **tenant** is the business that pays you. A **scope** is one isolation domain inside that business. **Diagram — a tenant and its scopes.** - **Tenant** (billing · identity · the contract) — slug · status · one Identity DO for all its people. Holds no operational rows of its own. - It contains scopes: `stockholm` (branch, active · eu), `göteborg` (branch, active · eu), `malmö` (branch, provisioning). - Every scope is: its own database — one SQLite file, or one Durable Object; one operation at a time, run to completion; kind is your vocabulary: brf, branch, brand, clinic…; jurisdiction and bound version are fixed per scope. - **No query crosses these lines. There is no join between two scopes.** - parentScopeId is null on every scope today. The column exists so a deeper tree is an addition rather than a migration. The tenant is who you bill and who can log in. The scope is where data lives and where consistency is decided — which is why isolation is a property of the substrate here, not a WHERE clause somebody has to remember. What a scope *means* is your vocabulary, not the kernel's. It is a `kind` string the kernel never branches on: a housing association for a property manager, a branch for a chain, a client company for an agency, a brand for a publisher. Users belong to the tenant, to a scope, or to several scopes with different roles in each. This shape is in nearly every vertical B2B product, and it is close to impossible to retrofit — which is why it is kernel-owned rather than a convention you follow. Two details are worth knowing early: - **Scope IDs are globally unique ULIDs**, not per-tenant counters. An event or an opaque reference never needs the tenant to disambiguate. But every kernel API still takes the pair `(tenantId, scopeId)` and cross-checks it — so a confused-deputy bug in calling code **fails closed** rather than resolving to somebody else's scope. - **`parentScopeId` exists and is always `null` today.** The column is there so deeper trees are an additive change later rather than a data migration. The full entity definitions are in [Tenants & scopes](https://substrat.net/concepts/tenancy.md). ## One scope, one database Here is the part that matters. A scope is not a partition key. It is **its own database**. On the local adapter, that is one SQLite file. On the hosted runtime, it is one SQLite-backed Durable Object. There is no shared cluster and no shared table with a `tenant_id` column in it. A query issued inside a scope is issued against storage that contains that scope's rows and nothing else. **Diagram — the topology, adapter-neutral.** Vertical code reaches data one way, and every scope feeds one spine. 1. **Vertical** (your code, TypeScript · often AI-built) reaches the host by **@substrat-run/kernel API only**. 2. **Scope host** (kernel) — `getScope(principal, tenant, scope)` returns a capability stub. holding a stub is the authorization. 3. Each scope is its own database + ACL, serialized — one op at a time — drawn here as Scope · branch #1 and Scope · branch #240, but there are as many as the tenant has. 4. Every scope emits **events, kernel-stamped** into the **Event spine** (kernel-owned): audit · 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. Follow the consequences: **Cross-tenant reads are not prevented, they are unavailable.** There is no `WHERE tenant_id = ?` to forget, because the other tenant's rows are not in the database you are querying. The most common multi-tenancy bug in the industry has no syntax here. **Blast radius is one scope.** A corrupted row, a runaway query, a migration that throws — each is contained to one customer's one domain. There is no lock contention between tenants because there is no shared lock. **"Give me a copy of production" is a file copy.** Snapshots, forks and preview environments (chapter 8) are cheap because a scope's entire state is one self-contained thing. On a shared cluster this is a export-filter-import project; here it is a copy. **Deleting a customer is deleting databases.** Reaping one scope destroys one file; reaping a tenant walks every scope beneath it and then clears the tenant's own PII and configuration rows. Which is a real property when somebody invokes a right to erasure, and a real hazard too — chapter 13 covers what actually frees those bytes, because the answer is less automatic than you would expect, and because the tombstones and the admin log are built to survive it. The cost is equally real: **you cannot join across scopes**. A question like "how many work orders did this tenant complete this quarter, across all forty branches" is forty reads and a fold, not one `GROUP BY`. [Reads & scaling](https://substrat.net/concepts/reads.md) is the page on how that is handled — projections, per-tenant D1, and what is honestly still open. ## Strict serialization Inside one scope, **one operation runs at a time, to completion**, before the next one starts. This is a stronger guarantee than most databases give you and it eliminates a whole category of code. No interleaved read-modify-write. No lost update. No row locking in module code, no `SELECT … FOR UPDATE`, no optimistic-retry loop. A handler that reads a balance, decides, and writes it back is correct as written, because nothing ran in between. It is enforced explicitly, on both adapters, by a per-scope task queue — about fifteen lines of code: ```ts export class OperationQueue { private tail: Promise = Promise.resolve(); enqueue(op: () => Promise | T): Promise { const result = this.tail.then(op); // The chain must survive failures; callers still see the rejection. this.tail = result.catch(() => undefined); return result; } } ``` On Cloudflare this queue sits *in front of* the Durable Object's own input gate rather than relying on it. The gate is real, but it over-delivers: it permits subtler interleavings around non-storage awaits. Kernel and module code are allowed to depend on strict serialization and nothing weaker, so the adapter enforces strict serialization itself instead of inheriting whatever the platform happens to provide. The two adapters then mean the same thing by "serialized", which is what lets one conformance suite hold both. Serialization is **per scope**. Two operations on the same scope queue. Two operations on different scopes, whether in the same tenant or not, run at the same moment on different databases, and neither can slow the other. Chapter 4 covers what a slow operation holds, and how a vertical keeps genuinely slow work out of the queue. The thing to notice is the pattern, because it repeats: where the platform's guarantee is *close* to the contract, Substrat implements the contract anyway rather than documenting the gap. ## The clone boundary Inputs and results cross the scope boundary as **structured clones** — even when the caller and the scope are in the same process. So module code can never hold a reference to something outside its scope, and outside code can never hold a reference into it. There is no object shared across the boundary to mutate by accident. Locally this is deliberate extra work; on Cloudflare it is free, because the boundary is a real RPC hop. Doing it locally too is what stops "works on my machine, fails in production" from being a category of bug — the local runtime is the strict one. ## Ambient tenancy Once you hold a scope stub, you never pass a tenant or scope id again. ```ts const stub = await host.getScope(principal, tenantId, scopeId); await stub.invoke('workorder/create', { title: 'Leaking radiator' }); ``` The handler behind `workorder/create` receives a context already bound to that tenant and that scope. It has no parameter for them, so there is no parameter to get wrong, and no call site where the right ids could be passed to the wrong operation. Holding the stub *is* the authorization to talk to that scope — it is a capability, and minting one is gated (chapter 3). The scope still re-validates every call against its own access rules, because a capability that is never re-checked is a capability that cannot be revoked. ## Where a scope lives is decided once Two fields are fixed at provisioning and never change afterwards: **`jurisdiction`** — `eu`, `us`, or `global`. Data residency is a property of the scope from birth. `global` is the honest name for unconstrained, which is what every scope is today; `eu` and `us` name guarantees whose enforcement (Durable Object jurisdiction subnamespaces, Regional Services) is not built yet, so provisioning currently gates them. The vocabulary ships ahead of the enforcement deliberately, precisely *because* the field is immutable: a scope that took a value nobody could name later would be a data migration rather than a config change. **`storageShape`** — `A` (the DO's embedded SQLite is primary) for everything today; `B` (a control-plane DO fronting per-tenant D1) is designed and not built. A Durable Object cannot relocate. That is not a Substrat limitation but a platform one, and it is why these are provisioning-time decisions rather than settings. ## The states a scope moves through ``` provisioning → active ⇄ suspended ↓ archiving → archived → reaped ``` `active` serves traffic. `suspended` fails closed — every `getScope` refuses — but the data is intact and the transition is reversible. `archived` is inert and reversible; the bytes are still there. **`reaped` is terminal**: the storage has been wiped, the directory row survives as a tombstone for audit history and to burn the slug, and there is no restore. Tenants have a parallel ladder — `active`, `suspended`, `deleting`, `reaped` — where `deleting` is a reversible grace state that makes every scope under it inert without reclaiming anything. What is worth flagging here, and what chapter 13 returns to: **nothing moves a scope from `archived` to `reaped` on its own unless you have configured it to**. Cloudflare never garbage-collects a Durable Object. An archived scope's bytes persist indefinitely until something explicitly wipes them, and the sweep that can do that is opt-in because the operation is irreversible. --- **Next:** [The path of one request →](https://substrat.net/book/03-one-request.md) --- # 3. The path of one request Source: https://substrat.net/book/03-one-request.md Someone opens a browser, types a hostname, and clicks a button that creates a work order. This chapter is every hop between that click and the committed row, in order. It is the chapter the reference does not have. [Router](https://substrat.net/platform/router.md) stops at the service binding. [`@substrat-run/vertical-host`](https://substrat.net/reference/vertical-host.md) starts at `/internal/*`. [Operations & the scope host](https://substrat.net/concepts/scope-host.md) starts at `invoke()`. Each describes its own hop well and none of them describes the joins, so here they are. ``` browser │ https://acme-north.example.com/api/invoke ▼ [1] router worker hostname → (tenant, scope, vertical, surface) │ x-substrat-tenant / -scope / -surface / -vertical / -router ▼ [2] dispatch env.DISPATCH.get(deploymentRef) │ ▼ [3] the vertical's worker readRoutedNode → authenticate → principal │ ▼ [4] CloudflareScopeHost lifecycle gates → lazy migrate → mint stub │ stub.invoke('workorder/create', input) ▼ [5] ScopeDO queue → transaction → parse → guards → handler │ ▼ [6] post-commit outbox → consumers │ ▼ response ``` ## [1] The router finds the door One worker sits in front of every vertical. Its entire job is: turn a hostname into a `(tenant, scope, vertical, surface)` target, and forward. It resolves that against the control-plane directory, uncached, once per request. Not caching a route is a deliberate choice rather than an omission: a cached route that keeps serving a suspended tenant blunts suspension, and suspension is a live weapon — it is what you reach for when a customer must stop being served *now*. The cost is a directory read per request; the alternative is a suspension that takes effect eventually. Only `active` hostname bindings resolve. A domain still validating its DNS, or one whose certificate failed, is simply unknown and gets "No application is configured for this hostname." Notice what the router deliberately cannot do. It binds what forwarding needs — the control-plane directory it reads, the dispatch namespace it forwards into — and **no `SCOPE` namespace**. It resolves names; it has no way to open a scope's database. That boundary is a deployment fact written into its wrangler config rather than a rule somebody follows. Handing the router the full scope host would have saved a file and given the name-resolution worker authority over every tenant's data. It also does not re-check tenant suspension, even though it reads the directory and could. `getScope` owns that, inside the vertical. A second enforcement point is a second thing that can disagree with the first; the router's job is to find the door, not to decide who may open it. ### The trust boundary The router forwards with the resolution in headers: ``` x-substrat-tenant: 01J8F... x-substrat-scope: 01J8G... x-substrat-surface: app x-substrat-vertical: fsm x-substrat-router: ``` The vertical trusts these **absolutely** — they name whose data it is about to serve. Two things make that safe, and both are required: 1. **Vertical workers have no public route.** `workers_dev: false`, no route; reachable only by service binding or dispatch from the router. 2. **`ROUTER_SECRET`** — the same value on the router and every vertical, presented as `x-substrat-router` and verified in the kernel's `readRoutedNode`. The second exists because the first is a deployment fact and `workers.dev` is on by default. One forgotten toggle makes (1) false with nothing in the code noticing, and the consequence is a cross-tenant read. Both halves **fail closed**. A router with no `ROUTER_SECRET` answers 500 to everything. A vertical deployed without one refuses any asserted node with a `RouterAssertionError` rather than trusting unsigned headers — this used to be a trust-by-default, which meant a worker missing its secret would accept a forged tenant from anyone who could reach the script directly. The only opt-out is `ALLOW_DEV_NODE`, which names an un-routed local instance and authenticates nobody. And the router **strips every inbound `x-substrat-*` header by prefix** before setting its own, so a client cannot forge a node by simply sending one. Stripping by prefix rather than by name is why adding a sixth asserted header later cannot reopen the hole. ## [2] Dispatch With a target in hand, the router picks the worker. If the resolved route carries a `deploymentRef` — the Workers-for-Platforms handle for the scope's bound version — it dispatches into the namespace: `env.DISPATCH.get(deploymentRef)`. This is how customer-pushed verticals are reached, and it is the normal path. If there is no bound version, it falls back to a static `VERTICAL_` service binding, the original shape, kept for routes that predate the registry. Every dispatch also carries the **outbound policy** for that version — the declared egress allowlist from its manifest — handed to the egress worker through the dispatch binding's outbound parameters. The router never inspects that list; resolution produced it and the egress worker enforces it. Chapter 5 picks this up where connectors do. Two facts fall out of dispatching on `deploymentRef` rather than on the vertical's name. Each scope is bound to a *version*, so two scopes of the same vertical can be running different code at the same time — which is what makes previews and staged rollouts possible at all (chapter 8). And the router does not re-check jurisdiction: residency is pinned by configuration ahead of this worker and by the scope's DO placement below it, and a third enforcement point here could only ever disagree with those two. ## [3] The vertical authenticates a person Now we are inside the vertical's own worker, an ordinary Hono app. The first thing registered on it is the invocation log, which writes one tenant-stamped line for this request and mints the **invocation id** that every event the request emits will carry (chapter 10). It is first because Hono runs handlers in registration order, and a log mounted below a route cannot see that route. Then the worker does three things before any Substrat API is involved. **It reads the asserted node.** `readRoutedNode(request.headers, { expectedSecret })` returns `(tenantId, scopeId, surface, verticalSlug)` or throws. This is the vertical's side of the router contract, and it lives in the kernel because every vertical needs it and none of them should re-derive how to trust it. **It authenticates the caller.** This is where OIDC happens — a session cookie, or a sign-in redirect to the issuer and back. The vertical is a relying party; it runs no credential store of its own. What it gets back is a `sub`. **It maps that `sub` to a principal.** The authenticated subject is not yet an identity in this scope. The per-tenant identity directory resolves it to a `PrincipalId`, or to nothing — in which case this person has authenticated successfully and is still allowed to do nothing at all. Chapter 6 is about that line. The important thing about this step is that it is the *vertical's* code, not the kernel's. Substrat has an opinion about authorization and only a seam for authentication. [Authentication & identity](https://substrat.net/concepts/identity.md) is that seam. The same declared operations are served twice over, with no extra declaration: as REST routes described by an OpenAPI document, and as an **MCP server at `/api/mcp`**, with one tool per operation that faces HTTP. An agent holding a user's token can do exactly what that user could do with `curl`, behind the same authentication and the same `ctx.check` inside the operation. Exposing an operation is not authorizing it. A refused tool call comes back as a tool error the agent can read, not as a failed session. Alongside this, the platform's own management routes are mounted in one call — `mountPlatformSurface(app, deps)` — which owns every `/internal/*` route the control plane calls to provision, reconcile, snapshot, export, restore and configure this install, behind a platform-secret gate that fails closed when the secret is unset. Those are not on the request path a user takes; they are how chapters 8 and 13 reach in. ## [4] Getting a scope stub ```ts const stub = await host.getScope(principal, tenantId, scopeId); ``` `CloudflareScopeHost` is the coordinator. It is **stateless**, rebuilt per request, and it does two things before it will hand back a stub. **Lifecycle gates.** `validateScopeAccess(tenantId, scopeId)` is evaluated durably in the control-plane DO. It refuses a scope that is not servable and a tenant that is not servable — suspended, deleting, archived, reaped, or simply not in this tenant. This is the K-3 fail-closed path, and it is where suspension actually bites. A throw here propagates out; there is no degraded mode. **Lazy migration.** `migrateAndRecord(scopeId)` asks the scope to bring itself to the current migration frontier. Migrations are not run at deploy time across the fleet; each scope migrates **on wake**, inside its own serialization domain, the first time anyone touches it after a version change. If it throws, the coordinator makes a best-effort attempt to record *why* into the directory — and then rethrows the original error, on purpose, because a broken recorder must not replace a diagnosable migration failure with a confusing one. A scope whose migration failed fails closed and serves nothing, which is what stops it from rendering as healthy in the console. Only then is the stub minted. **Holding the stub is the authorization to talk to that scope** — but it is not the authorization to do anything in particular, and the scope re-validates every call regardless. ## [5] Inside the Durable Object `stub.invoke('workorder/create', input)` is an RPC into the scope's own Durable Object. The structured-clone boundary is that RPC hop. What happens on the other side, in order: 1. **The queue.** The call is enqueued on the per-scope `OperationQueue`. It waits until the operation ahead of it has run to completion. 2. **The transaction opens.** Everything below happens inside `ctx.storage.transaction(async …)` — the DO analogue of `BEGIN IMMEDIATE`, which rolls back on a throw *across awaits*. 3. **The operation resolves.** A name like `workorder/create` is looked up in the module registry. A module whose entitlement the tenant does not hold did not register at all, so its operations simply do not resolve — there is no half-loaded engine and no operation that exists but refuses. 4. **The input is parsed.** The host applies the module's declared Zod schema before any guard and before the handler. Handlers do not hand-parse. This happens on *every* path in — HTTP, test, seed, schedule — so a declared input that nobody validates is not possible rather than merely discouraged. 5. **Guards run.** Manifest guards, and any concurrency precondition the caller sent (`If-Match` against an entity's version). Idempotency keys are resolved here too: a replayed key returns the original result without re-running anything. 6. **The handler runs.** Its first line is a permission check. Chapter 4 is this step. 7. **Commit, or roll back.** On success the transaction commits — the domain rows, the events, the links, the grants, all of it, atomically. On a throw, all of it is gone. There is one deliberate exception to "on a throw, all of it is gone". A **permission denial is recorded after the rollback**, as its own write, outside the transaction that just disappeared. That is the whole point of it: the denial is precisely the write the refused operation could not make, so keeping it inside the rolled-back transaction would erase the only evidence the refusal happened. Chapter 6 returns to it. ## [6] After the commit The moment the transaction commits, the DO drains its outbox to consumers, each delivery in its own transaction and still inside this operation's turn in the queue. That is chapter 5, and it is where most of the system's interesting behaviour lives. Back on the coordinator, outside the scope, any executors and connectors due for the new events run next. If the operation raised platform intents, the vertical flags its response so the router can ask the control plane to act on them straight away (chapter 5, "the kick"). Then the result travels back out: clone boundary, coordinator, vertical worker, JSON, router, browser. ## What the local runtime does differently Nothing that matters, which is the point. Locally, a scope is a SQLite file instead of a Durable Object, the coordinator is in-process instead of across an RPC, and there is no router — you address the instance directly with `ALLOW_DEV_NODE`. The queue, the clone boundary, the transaction semantics, the lazy migration, the fail-closed addressing and the stamped envelopes are the same, because the same [conformance suite](https://substrat.net/reference/contract-tests.md) runs green on both, unchanged, in Node and in real `workerd` against real Durable Objects. Neither side is a mock. That equivalence is what makes local development deterministic and CI cloud-free, and it is the reason a vertical moves from a laptop to Cloudflare with no code change. --- **Next:** [What a handler can and cannot do →](https://substrat.net/book/04-inside-an-operation.md) --- # 4. What a handler can and cannot do Source: https://substrat.net/book/04-inside-an-operation.md We left the last chapter one step from the handler. This chapter is that step: what an operation is handed, what it is denied, and why each denial is worth its inconvenience. ## The whole surface An operation handler receives exactly one thing. ```ts async function createWorkOrder(ctx: OperationContext, input: CreateInput) { assertAllowed(await ctx.check('workorder:create')); const id = ulid(); ctx.sql.exec( 'INSERT INTO wo_orders (id, title, state, created_at) VALUES (?, ?, ?, ?)', [id, input.title, 'scheduled', ctx.now()], ); ctx.emit({ type: 'workorder.created', entity: { entityType: 'workorder', entityId: id }, piiClass: 'none', payload: { id, title: input.title }, }); return { id }; } ``` That is representative, not simplified. `ctx` is the entire capability surface: | | | |---|---| | `ctx.tenantId` / `scopeId` / `principal` | where this runs and on whose behalf, read-only | | `ctx.sql` | `query` / `exec` against **this scope's** database | | `ctx.now()` | the operation's instant, and the only clock | | `ctx.check(perm, entity?)` | a permission decision | | `ctx.emit(event)` | a domain event, into the outbox | | `ctx.page(entityType, params)` | one page of a declared list: filters, sort, keyset cursor, total | | `ctx.search(entityType, term)` | full-text lookup over declared searchable fields | | `ctx.versionOf(entity)` | an entity's current version, for `If-Match` preconditions | | `ctx.link(child, parent)` | a permanent entity relationship | | `ctx.grant` / `ctx.revoke` | user-initiated per-entity sharing | | `ctx.canAssign(roleKey)` | whether the caller holds everything a role would confer | | `ctx.atomic(fn)` | a sub-transaction | | `ctx.entitlement(key)` / `entitlements()` | the tenant's plan, quota and expiry for a module it holds | | `ctx.requestPlatform(req)` | ask the platform for a privileged effect | | `ctx.platformRequests(filter)` | read back what the platform did with those | | `ctx.sealToConnection(provider, value)` | encrypt a value so only that connector can open it | **Nothing on `ctx` checks a permission except `ctx.check`.** `page`, `search` and `versionOf` read data, and the operation's own `assertAllowed` comes first, as always. A connector's handler gets a *different* context with a scoped `connection(provider)` handle. That handle is host code's, not an operation's, and it appears later in this chapter. There is no `env`. No `fetch`. No database handle. No `process`. No import of anything that could provide one. Capabilities come from `ctx` and from nowhere else, and that is the single sentence the rest of this chapter unpacks. ## `ctx.sql` — and why it is the only way in `ctx.sql` is already inside one scope's database. It has no parameter for tenant or scope because there is nothing else it could address. Module code may not import `better-sqlite3`, an adapter, `node:*`, or `cloudflare:workers`. Most of those bans are obvious portability hygiene. **`cloudflare:workers` is not, and it is the sharpest one.** That module exports an ambient `env`. One import hands module code every binding and secret the script declares — including its own `SCOPE` Durable Object namespace, which reaches *another scope's data*, precisely where `ctx.sql` cannot. It is a single import that dissolves the isolation the whole architecture is built on, and it looks completely innocuous in a diff. The linter refuses it (`node tools/boundary-lint.mjs`, rule R2), and harness code — `seed.ts`, `server.ts`, tests — is exempt because it is not module code. One rule inside `ctx.sql` is enforced by the **runtime**, not only the linter: a write whose target is a `_substrat_*` table is refused, on both adapters. Those tables are **the spine**: the kernel's own tables, created in every scope database next to your module's. The outbox and the delivery journal (chapter 5), the denial log, the platform-intent queue, the permission tuples and their projections (chapter 6), the migration journal, schedule state, attachments and idempotency records all live there. The directory has a spine of its own, holding the admin log, the access log, identities and entitlements. Only the kernel writes the spine. Forging a row in it is forging the audit trail or the permission model. This is a mechanism rather than a lint rule because the linter does not run on the hosted push path, and a guarantee that holds only where CI happens to look is not a guarantee. Only the write's *target* is judged, so `INSERT INTO my_timeline SELECT … FROM _substrat_outbox` is fine. Reading the spine **is** sanctioned, and there are helpers for it — use them: `readTimeline` and `readHistory` from `@substrat-run/kernel` take an `EntityRef`, page like an ordinary list read, and decode the envelope. `readHistory` additionally returns the payload, the authorization chain, the impersonation stamp, the PII class, the emitting operation and the version the emitting code was deployed as. On most of those a `null` is a *fact* — the payload was erased, nobody was impersonating, a consumer emitted it — which a hand-rolled `SELECT` would read as missing data instead. Neither helper checks a permission; the caller does that first, as always. ## SQL strings, and why there is no query builder `ctx.sql` takes a SQL string and a parameter array. There is no ORM, no query builder, and no typed query layer generated from the model. The model emits tables, migrations, an API description and a browser client, but not queries. That can look like an omission. It is a choice, and the reasoning is worth checking against your own threat model, because the protections are real and so is one gap. **What a query builder would buy, and where you already have it.** Builders earn their keep in three places: preventing injection, composing dynamic filters, and hiding dialect differences. Here: - **Binding is positional.** `ctx.sql.query('SELECT … WHERE id = ?', [id])` sends the value as a bound parameter, never as SQL text. That is the same protection a builder gives, provided values go in the array. - **Dynamic reads are kernel-composed.** A list with user-chosen filters, sort and cursor is `ctx.page`, which builds the `WHERE`, `ORDER BY`, keyset comparison and `LIMIT` from what the operation *declared* filterable and sortable, and refuses anything else by name. The query that most tempts people to concatenate strings is the one you do not write. - **There is one dialect.** Both adapters are SQLite, so there is no dialect to hide. **Why injection is survivable when it happens.** Everything else here is defence in depth, and it is deep: - **Inputs are parsed before the handler runs** (below). An `id` declared as a ULID cannot arrive as `1 OR 1=1`. - **The database holds one scope.** An injected statement runs against a database containing that scope's rows and nothing else. It cannot read another tenant, because there is no other tenant in the file (chapter 2). - **The spine refuses writes regardless of where the SQL came from.** The spine-write check parses every statement in the string, including stacked ones, so an injected `; INSERT INTO _substrat_tuples …` is refused like a hand-written one. **The gap, stated.** Nothing mechanical stops a handler from writing `` `… WHERE name = '${input.name}'` ``. No lint rule inspects SQL strings for interpolation. Engines interpolate constant column lists on purpose, which is why a crude rule would drown in false positives. So "values go in the array" is a review convention, not a mechanism. And on the local SQLite adapter, statements that reach past the scope's own file, such as `ATTACH`, are not refused by the kernel. The spine check deliberately leaves them to the runtime, and on Durable Objects the runtime restricts them. On self-hosted SQLite, an injected `ATTACH` is the one path from a scope's SQL to another file on the same disk. Why plain SQL at all: a migration is a human checkpoint, and SQL is what a reviewer can read. A query in a diff is a query, not a builder chain whose SQL you have to imagine. ## `ctx.now()` — the only clock `new Date()` and `Date.now()` are boundary-lint violations (R6), the same class of ban as `node:*`. Two reasons, and the second is the one that bites. First, **stability**: `ctx.now()` returns the same instant for the whole invocation, stamped when the context was built. Two rows written in one transaction cannot disagree about when they were written, and an event cannot disagree with the row it describes. Second, **testability**: anything genuinely time-dependent — an absence window, a metering period, a booking hold, a grant expiry — is untestable against the wall clock except by sleeping or by shrinking the window to zero, both of which produce tests that assert less than they appear to. The pure host takes a `clock`, so a scenario uses `manualClock` or `frozenClock` and asserts an elapsed-time transition directly. The Durable-Object host declares `clock?: never` and means it: the reads that matter happen inside the ScopeDO that workerd constructs, which a host option cannot reach. So grant *expiry transitions* are held to the contract on SQLite only, and so is facet *recency* — that a bucket's `lastSeen` is its latest event, which the wall clock cannot tell from its first. Those are the two suites the two adapters do not share, for that one reason, and it is written down rather than quietly tolerated. Code that must read the real clock — a JWT whose `exp` a remote server judges — opts out with a reviewable `boundary-lint-allow R6` block. ### Timestamps are text, and a fixed width of it Timestamps are stored as **ISO 8601 text**, never epoch integers. `ctx.now()` returns an `Instant`, and every `Instant` is normalized to UTC in exactly one shape: `2026-09-17T08:41:05.123Z`. Offsets are converted and precision is fixed at milliseconds, so every value is exactly 24 characters. The reasons, in order of how much they matter: - **Comparison is lexicographic, and correct.** Grant liveness is `expires_at > ?`, a metering close horizon is a string comparison, and `ORDER BY occurred_at` sorts. Text sorts chronologically only when every value shares a zone and a shape, and the normalization guarantees that. An offset timestamp compared as text against a `Z` timestamp is simply wrong, which is why the parser rewrites rather than accepts. - **No unit convention.** An integer column cannot say whether it holds seconds or milliseconds, and a system with many modules eventually has both. - **It reads correctly in a `.sqlite` file someone opens by hand**, in an exported dump, and in an event payload. The row and the event announcing it hold the identical string. A fair worry about text is that values change size on update, which in some storage engines means rows move and pages fragment. It does not apply here, for two reasons. First, the normalization makes every instant the same length, so updating `2026-09-17T08:41:05.123Z` to a later instant rewrites 24 bytes with 24 bytes. The row does not grow. (Moving a column from `NULL` to a value grows a row whatever its type.) Second, SQLite has no append-only heap and no MVCC row versions. An update rewrites the record inside its B-tree page, and space freed inside a page is reused by that page. The real cost is width. SQLite stores an epoch integer in 4 bytes as seconds or 6 as milliseconds, so 24 bytes is four to six times as much per timestamp column. It is a real cost, it is small next to a row's other text, and it buys comparisons that cannot silently go wrong. For dates without a time, such as a birthday or a leave day, the contract is `calendarDate`, `YYYY-MM-DD`, which sorts the same way. ## `ctx.check` — first line, every time Every operation's first line is a permission check: ```ts assertAllowed(await ctx.check('workorder:create')); ``` Not "usually". The reason to make it unconditional is that the alternative is a judgement call at every handler, and judgement calls are where the missing check comes from six months later. Portal-style walks — "which of these can this person see?" — use the entity-scoped form, `ctx.check(perm, entityRef)`, per row. Chapter 6 is how those are answered without a network call. A refused check throws `PermissionDenied`, the transaction rolls back, and the denial is then written **outside** that transaction so it survives. You get a queryable record of what was refused, to whom, on which operation, under which impersonation session — which is the data you want when a customer says "it says I can't" and you need to know which permission key to look at. ## `ctx.emit` — and the envelope you do not control A mutation emits an event. The kernel stamps the envelope: id, occurred-at, tenant, scope, actor, authorization chain, impersonation, the emitting operation, the deployed version. The module supplies the type, the entity, the PII class and the payload — and the payload is validated. You cannot suppress the stamp, forge an actor, or emit on another scope's behalf, because `emit` is below the API surface rather than beside it. That is what makes the event stream an audit log *by construction* instead of by discipline. Two rules about payloads matter enough to state here, and chapter 5 explains them: - **Events are fat.** The payload carries everything a consumer needs, so the consumer snapshots rather than joins. A consumer that has to read back across a module boundary to interpret an event is an event that was emitted too thin. - **PII classification is mandatory.** Not defaulted — declared. It is what makes erasure a query rather than an audit. ## `ctx.atomic` — the only way to catch an engine error This one is a real trap, and it has a mechanism behind it. An engine call composed inside your transaction has **no boundary of its own**. So a bare `catch` around it leaves you holding its partial writes — the rows its invariants were protecting — and then commits them: ```ts // WRONG — boundary-lint R7 rejects this try { await completeWorkOrder(ctx, { orderId, billable }); } catch { // the engine's half-written rows are still here, and will commit } ``` The correct shape gives the engine call a boundary: ```ts try { await ctx.atomic(() => completeWorkOrder(ctx, { orderId, billable })); } catch { // the engine's rows, events, links, grants and platform intents are all gone; // your own writes survive, and it still commits once } ``` A succeeded `atomic` is still **provisional** — if the operation later throws, its writes go with everything else. Sub-transactions nest but must not interleave; starting two concurrently throws. Outside `ctx.atomic`, catching an engine error is forbidden and **R7 rejects it with no escape hatch** — unlike R5, R6 and R8, which all have reviewable `boundary-lint-allow` blocks. There is no legitimate reason to swallow an engine error unprotected. `try`/`finally` with no `catch` is fine, and so is a catch that always rethrows: the operation still fails and the whole transaction still rolls back. ## What is not here: the network There is no `fetch` in module code. None. Boundary-lint rule R3 refuses it, along with any HTTP client import. Effects on the outside world are somebody else's job. There are three mechanisms, and all three share one shape: **the module writes a durable row in its own transaction, and something outside the transaction acts on it afterwards.** - **An executor** is a handler the *host* registers against an event type. It runs after the commit, outside the scope, with platform authority (`HostAdmin`) instead of a `ctx`. It is the shape for effects that are not scope-local, such as writing tenant-wide directory state, because a module cannot write to another database inside its own transaction without leaving an orphan on rollback. - **A connector** is an executor that is also handed a per-tenant credential and permission to call one provider over the network, and nothing more. Signing, accounting and planning integrations are connectors. A connector is host code, never module code. - **A platform intent** (`ctx.requestPlatform`) is how a sandbox-clean vertical asks for a privileged action it has no authority to perform itself. The intent is a durable row written atomically with your operation. The platform pulls and executes it later, and `ctx.platformRequests()` reads back what happened, including a failure, so a screen can say "this did not go out" instead of showing something that only appears to have. All three are chapter 5. ## What an `await` holds Handlers are `async`, so a fair question is what happens while one waits. Does a slow operation block other requests on its scope, or on every scope? **Its own scope only.** Chapter 2's queue gives each scope one turn at a time, and the turn lasts from the moment the operation starts until its post-commit work is done. On Durable Objects, post-commit work includes draining in-scope consumers (chapter 5). So an operation that takes two seconds, or a consumer that takes two seconds, delays the *next operation on that scope* by two seconds. Everything else runs on: - **Other scopes** are other Durable Objects, or other actors on the local adapter, each with its own queue. A slow scope costs nobody else anything. - **The vertical's worker is not serialized.** Requests to it run concurrently. Only the hop into a scope waits its turn. - **Executors and connectors on Durable Objects run on the coordinator**, after the scope's turn has ended. A slow provider delays that HTTP response and holds no scope's queue. (On the local SQLite adapter they currently run inside the scope's turn. That is a real difference between the two hosts, and it only shows up under a slow connector.) In practice a handler has almost nothing slow to wait on, and that is the ban on the network working as designed. `ctx.check`, `ctx.entitlement` and `ctx.canAssign` read scope-local projections (chapter 6). `sealToConnection` is local Web Crypto. Nothing on `ctx` crosses a network on the normal path, so the queue drains quickly *because* module code cannot reach anything slow. Nothing in the kernel times an operation out, either. The platform's own limits are the only ceiling. ### When the work genuinely takes time Some work is slow by nature: calling a language model, rendering a document, waiting on a provider. The pattern is to **keep the slow part out of the operation and bracket it with operations**: ```ts // in the vertical's worker (harness code, not module code) const context = await stub.invoke('desk/prepare-answer', { conversationId }); // fast: read + record intent const answer = await models.answer(context); // slow: no scope turn held await stub.invoke('desk/record-answer', { conversationId, answer }); // fast: write + meter + emit ``` The model call holds no transaction and no queue turn. Each operation around it is short, checks its own permission, and commits on its own. The reference support-desk vertical answers questions exactly this way, with the whole sequence handed to `waitUntil` so the user's request returns first. If the work must survive the isolate going away, make the first operation record that it is pending, so a sweep or the next request can find unfinished work. The other option is to make the slow part an effect: emit an event, and let a connector do it with retries (chapter 5). Pick by one question: does the vertical need the result *inside* its own logic (bracket it), or is the result a side effect in someone else's system (emit it)? ## Parse, don't trust — and the host is what parses Operation inputs go through Zod schemas at the boundary, and **the host applies them**. A module passes `operationInputs: operationInputsOf(ops)` beside its `operations`, and every invocation is parsed before the guards and before the handler, on every path in — HTTP, test, seed, schedule. Handlers do not hand-parse. The distinction is not stylistic. If parsing were the handler's job, a declared input that nobody validated would be an ordinary oversight. Because it is the host's job, it is not possible. ## Why all of it is worth the inconvenience Each rule in this chapter closes a specific failure that is otherwise silent: | Rule | The failure it closes | |---|---| | `ctx.sql` only | reading another tenant | | bound parameters, `ctx.page` for dynamic reads | injection, and filters nobody declared | | no `cloudflare:workers` | one import, every secret and every scope | | no spine writes | a forged audit trail | | `ctx.now()` only | rows disagreeing about when; untestable time logic | | normalized ISO text | timestamps that compare wrongly | | no network in handlers | a slow provider holding a scope's queue | | check first, always | the guard that was never written | | kernel-stamped envelopes | an event that lies about who did it | | `ctx.atomic` to catch | committing an engine's half-finished state | | host-side input parse | a declared schema nobody applied | None of these is enforced by asking nicely. Most are linted, several are runtime mechanisms, and the ones that are only conventions are named as such in [Agent rules](https://substrat.net/guide/agent-rules.md) rather than allowed to look enforced. --- **Next:** [The life of one event →](https://substrat.net/book/05-one-event.md) --- # 5. The life of one event Source: https://substrat.net/book/05-one-event.md This is the chapter the reference genuinely does not have. Every fact in it is published somewhere — [Events & audit](https://substrat.net/concepts/events.md) has the semantics, the adapter pages have the machinery, [Modules & the manifest](https://substrat.net/concepts/modules.md) has the declarations — but no page walks the loop, so here it is, from `ctx.emit` to the consumer's commit, including every way it can fail. ## Emit is a write, not a send ```ts ctx.emit({ type: 'workorder.completed', entity: { entityType: 'workorder', entityId: id }, piiClass: 'none', payload: { id, lines, total }, }); ``` Nothing is sent. `emit` writes a row into `_substrat_outbox`, **inside the operation's transaction**, and returns. That is the whole foundation of the design. The event and the domain write commit together or not at all. There is no window in which the row exists and the event does not, and none in which an event announces a row that rolled back. No two-phase commit, no coordinator, no reconciliation job to find the mismatches — because the mismatch is not representable. The envelope is stamped kernel-side. The module supplies four things (type, entity, PII class, payload); the kernel supplies the id (a ULID, which is monotonic, so `ORDER BY id` is creation order), the instant — the *operation's* instant, so every event one operation emits carries the identical one — the tenant, the scope, the actor, the authorization chain, the impersonation session if there was one, the operation name, and the version the emitting code was deployed as. ### Fat payloads, and why A consumer must never need a cross-module read to interpret an event. So the payload carries everything: the billable lines, the prices, the totals — not an id to go look up. The consumer therefore **snapshots** rather than joins, and prices are frozen at the moment the event happened. Which is exactly what an invoice wants, and it stops being a stylistic preference the moment you notice the alternative: a consumer that reads back into the producer's tables is a consumer that breaks when the producer's schema moves, and that reads *today's* price for *last month's* work. And the consumer validates with **its own Zod parse**, never the producer's types. Importing a producer's type would make the contract a compile-time coupling; parsing the payload makes it a runtime contract, which is the only kind that survives two modules being on different versions. ### PII classification is mandatory `piiClass` is declared, not defaulted. It is what makes erasure a query rather than an audit: when a subject invokes a right to erasure, the payloads that must be cleared are the ones the class names, and the envelope — who did what, when — survives, because the envelope is the compliance record. ## Post-commit: the consumer loop The transaction commits. Immediately after, still inside the same invocation, the scope drains its outbox: ```ts // scope-do.ts, the tail of invoke() if (!replayed) await this.dispatch(tenantId, scopeId); ``` `dispatch` is small enough to describe exactly. It runs **up to 50 rounds**. Each round walks every registered module, and for each of that module's consumers, selects the outbox rows of the matching type that have **no row in `_substrat_deliveries`** for that `(event, module)` pair, oldest first. For each such event: ```ts await this.ctx.storage.transaction(async () => { const ctx = this.operationContext(systemPrincipal, tenantId, scopeId, { system: mod.id }); await consumer.handler(ctx, event); this.sql.exec( `INSERT INTO _substrat_deliveries (event_id, consumer_module, delivered_at) VALUES (?, ?, ?)`, event.id, mod.id, new Date().toISOString(), ); }); ``` Three things are load-bearing here. **Each delivery is its own transaction.** The consumer's writes and the journal row marking it delivered commit together. So a crash between the handler finishing and the journal being written cannot exist — either both happened or neither did. Delivery is exactly-once with respect to the journal, and at-least-once with respect to the world. **The consumer runs as a system actor.** Its context carries `{ system: '@substrat-run/engine-invoicing' }`, and that is what the audit trail shows for anything it writes. It is an ordinary in-scope operation in every other respect — same `ctx`, same rules, same transaction semantics, same inability to reach another scope. **The loop rounds because consumers emit.** A consumer's own `ctx.emit` writes another outbox row, which the next round picks up. Fifty rounds is the cascade limit, and the loop exits as soon as a round delivers nothing. An event emitted this way records **`causedBy`**, the id of the event whose delivery emitted it. It also inherits the originating request's **invocation id**, because the drain is part of the same call. Chapter 10 walks both links. **All of this happens inside the scope's turn.** The drain runs after the commit, but before the queue lets the next operation in. A slow consumer therefore delays the next operation on its own scope (chapter 4), and no other scope. ### When a consumer throws This is the part worth reading twice, because it is not what most queue-shaped systems do. ```ts } catch (err) { // Dead-letter (v0): journal the failure so one poison event // can't wedge the loop. Written outside the rolled-back txn. this.sql.exec( `INSERT INTO _substrat_deliveries (event_id, consumer_module, delivered_at, error) VALUES (?, ?, ?, ?)`, event.id, mod.id, new Date().toISOString(), String(err), ); } ``` The consumer's transaction rolls back — none of its writes survive — and a journal row is written **with the error**, outside that rolled-back transaction, so it persists. **In-scope consumers do not retry.** One failure is terminal for that `(event, consumer)` pair. The next round's query excludes the event because a delivery row now exists, so the loop cannot wedge on a poison event, and the failure is evidence rather than a silent drop. That is a deliberate v0 choice and the code says so. It is the right default for a consumer that is pure in-scope logic — such a consumer fails because of a bug or bad data, and retrying a bug five times produces five identical failures and a delay. It is the wrong default if you were expecting queue semantics, so: **a failed in-scope consumer needs a human or a replay, not a wait.** Effects that genuinely need retrying are not in-scope consumers. They are executors, and they work differently. ### Ordering Guaranteed within one `(scope, module)` pair, and not across them. Within a pair, the `ORDER BY o.id` on a monotonic ULID mint means creation order. ## The connector seam: executors Some effects are not scope-local. Adding someone to an organization writes tenant-wide directory state, outside any one scope's transaction. Sending mail leaves the building entirely. A consumer cannot do either, because a consumer runs *inside* the scope and can only touch that scope's data. So a module **asks**, and an **executor** effects: ``` module (in-scope) executor (out-of-band) ctx.emit('member.add-requested') ──▶ admin.addMember(...) commits WITH the domain write writes the audit row ``` ```ts host.registerExecutor('member-adder', 'member.add-requested', async (admin, event) => { // receives HostAdmin, not ctx — it acts with platform authority, // which is exactly what module code must never hold }); ``` Why not write directly from the module? Because that would be a write to another database inside a scope transaction: two independent commits, no coordinator, and an orphaned membership if the scope rolls back after the directory write lands. The event has no such hazard, because it enters the outbox in the **same transaction** as the domain write. A rollback leaves no event and therefore nothing to effect. ### Who registers executors in practice The membership example above is the textbook one, and it lives in the contract suite. In the running platform, the executor you will actually meet is a **connector**: an executor that is also handed a credential and one provider's worth of egress. An e-signature connector is the reference, registered against the event a protocol emits when it requests signatures. Where it runs depends on the host, and the difference matters: - **Self-hosted, or with a control-plane binding**, the host runs the connector on the coordinator, with the retry policy below. - **A hosted, sandbox-clean vertical has no credentials to hand anyone.** It declares the connector as a routing entry only, and the host turns each due delivery into a `connector:` **platform intent** (below). The control plane, which holds the credential, runs the connector when it drains that intent. A third kind of connector subscribes to no event. An accounting or planning integration that *pulls* data runs as a sweeper inside the platform sweep (chapter 9), on a schedule. ### Executors retry, and the ordering is the point An executor runs on the **coordinator**, not in the DO — it acts through `HostAdmin`, which is outside the scope entirely. So the drain is a read inside the DO, the effect happens outside, and the journal is written afterwards: ```sql SELECT o.* FROM _substrat_outbox o LEFT JOIN _substrat_deliveries d ON d.event_id = o.id AND d.consumer_module = ? WHERE o.type = ? AND (d.event_id IS NULL OR (d.next_attempt_at IS NOT NULL AND d.next_attempt_at <= ?)) ORDER BY o.id ``` "Due" means never attempted, or retrying and now past its next attempt time. Terminal rows — delivered, or dead-lettered with `next_attempt_at IS NULL` — are excluded by the join. The journal is written **after** the effect, on purpose. Claiming a delivery before running it would make delivery at-most-once and lose the effect on any crash in between. Writing afterwards means a crash mid-effect retries — which is why executors, like consumers, must be idempotent, with the event id as the idempotency key. Retry policy is **per executor**, not a host-wide constant, because the right answer differs: | | default | |---|---| | `maxAttempts` | 5, including the first; reaching it dead-letters | | `baseDelayMs` | 1000ms, doubling per attempt | | `maxDelayMs` | 300000ms (5 minutes) — the ceiling on the doubling | with jitter of ±20% on each computed delay. Those defaults suit a directory write. A connector making an outbound HTTP call wants a longer tail, and sets one. A failed attempt with **no next attempt time** is a dead letter — the row keeps the last error, and the drain report counts it. `ExecutorDrainReport` returns `attempted`, `delivered`, `retrying` and `deadLettered`, and those last two are the numbers a health surface reports. A caller that ignores them learns nothing, which is exactly the failure mode the older silent path had. ### Who actually retries Dispatch is **prompt**: the drain runs inline, in the request that emitted the event, so the common case completes before the response goes out. The outbox is the *backstop*, not the mechanism. The backstop needs something to run it, and this is where the reference splits across pages in a way that genuinely confuses people. It is the **scope sweeper** — an alarm-driven singleton Durable Object in the vertical's own deployment, calling `drainDue` over its roster of scopes. Not the platform sweeper, which is a different clock doing different work in a different place. Chapter 9 is both of them and the distinction. ### The trail joins Splitting a change across two halves would otherwise split its audit trail. So admin rows an executor writes carry `causedBy` — the id of the event that caused them. That is the event id rather than a separate correlation field, because the envelope already carries a unique kernel-stamped id, and reusing it avoids widening a frozen contract to say something it already says. ## Platform intents: asking rather than calling There is a third shape, for when a sandbox-clean vertical needs a privileged action — provisioning a sibling scope, say — and holds no privilege at all. `ctx.requestPlatform(request)` writes a durable row into this scope's `_substrat_platform_requests` spine, atomic with the operation, exactly as `emit` is. The platform pulls and executes it later, knowing the tenant inherently because it read this scope's DO. Origin fields are stamped kernel-side; the call returns the new id. Call it **after** your own permission check. Authorization is the vertical's decision; isolation is the platform's. And it applies backpressure: a scope holding the maximum pending intents throws rather than queueing without limit. Intent kinds are a closed set: provisioning a sibling scope or a whole tenant, archiving a scope, reconciling a managed tenant's entitlements, recording a model-usage line (chapter 12), and the `connector:` family. The platform uses one more kind for its own sweep records, and module code is refused it, so a vertical cannot forge a verdict about its own schedules. ### How fast an intent runs, and the kick An intent is **pulled**, never pushed, and by default the pull is the platform sweep: a fifteen-minute cron on the hosted control plane (chapter 9). An intent raised at 10:01 can therefore sit until 10:15. For an intent a user is waiting on, such as "provision my new branch", that is too slow. So there is a fast path, and it has three parts, each owned by a different party: ``` operation commits with ≥1 intent │ host fires onPlatformRequests(count) ← only after COMMIT, never on rollback ▼ vertical sets response header x-substrat-platform-request: 1 │ ▼ router sees the header → waitUntil(kickDrain) ← after the response has gone │ POST /internal/drain-scope {tenantId, scopeId} (service binding + platform secret) ▼ control plane drains that one scope now ``` The header carries no payload and no privilege. The control plane re-derives the tenant and vertical from the directory and simply pulls. A forged or spurious header costs one empty pull. The user never waits for the kick either, because the router runs it after returning the response. With it, an intent settles in seconds. **The part people miss is the vertical's.** The kernel exposes the signal and does not set the header for you. A vertical opts in when it mints its stub: ```ts const stub = await host.getScope(principal, tenantId, scopeId, { onPlatformRequests: () => c.header(PLATFORM_REQUEST_HEADER, '1'), }); ``` A vertical that skips this still works, and its intents wait for the sweep. That is the designed failure direction: the kick is advisory, and **a missed kick costs one sweep interval, never an intent**. It is also why "provisioning works but takes a quarter of an hour" is almost always a vertical that never wired the header, and not a slow platform. A drain that throws leaves the intent `pending` for the next pull. After 100 attempts (about a day at sweep cadence) it settles as `failed` and records an operational failure, so a stuck intent eventually becomes a visible one. ### Reading the outcome The outcome comes back. `ctx.platformRequests(filter)` reads this scope's own intent journal — newest first, filterable by kind and status — with the `result` or `lastError` the platform settled. That read exists for one concrete reason: a contract whose signature request settled `failed` can say so on its own screen, instead of showing a document that appears to be out for signature and is not. The kernel owns every write to that table, so a status is only ever the platform's answer. ### How the platform finds out Nothing leaves the scope inside the transaction. The intent is a row; something outside has to notice it. Two things do, and they are not alternatives. **Diagram — how committed work leaves a scope.** 1. **An operation commits** (in the scope · one transaction) — your rows · its events in _substrat_outbox; a platform intent, if it asked for one. 2. Two paths reach the control plane: - response flagged: **The router kicks** (latency · seconds) — returns the response to the user first; then asks for a drain · intents only, today. It names a scope, carries no data. - **The platform sweep** (backstop · every 15 minutes) — walks every active scope; catches anything a kick missed. It runs on a timer rather than a signal, and drains each active scope. 3. **The control plane drains the scope** (platform · the only thing that acts) — reads the rows itself, through the vertical's /internal routes; so a kick can speed up a scope's own work, never forge it. 4. It executes intents — **Run with platform authority** (intents) — executed outside the scope; outcome written back to its intent journal. 5. It ships events — **Shipped to the lake** (events · tier 2) — marked shipped only once the lake confirms; through the sweep only — no kick yet. The kick and the sweep are not alternatives. The kick makes it fast; the sweep makes it certain — a lost kick costs a wait, never a missed intent or event. Neither carries data: the control plane always reads the scope itself. The **kick** is the fast path. The vertical flags its response, and the router — the one hop that already knows which tenant and scope it just served — returns the response and then asks the control plane to drain that scope. The **sweep** is the guarantee: every 15 minutes the platform walks every active scope and drains what is pending, so a kick that was lost costs a wait and never an intent. The kick is safe for a reason worth stating. It carries no data. It names a scope, and the control plane reads that scope itself and re-derives the tenant from its own directory, so the most a kick can do is make a scope's own work happen sooner. ## Why an engine composed by event has no exports The invoicing engine consumes `workorder.completed` *and* `commerce.order-placed`, events from two different domains, without importing a single type from either producer. From them it builds a customer's **billing basis**, the lines an invoice will be issued from (chapter 12). It has deliberately **no in-scope exports**: the vertical emits, the engine consumes, and the vertical reads results back through the engine's own operations or by consuming its events. That is not an omission. The engine being the only writer of its rows is what keeps invariants like immutable-after-export safe from a half-finished caller. Chapter 7 is when to reach for that shape versus a direct call. ## Schema versions, and why dual-emit is not available Event payload fields are **frozen once shipped**. Rename, remove or retype means a `schemaVersion` bump, and a bump is a **replace** — you cannot emit v1 and v2 side by side during a deprecation window. The reason is mechanical rather than philosophical. Consumer dispatch selects on event *type* alone (`WHERE o.type = ?`), and the `schemaVersion` a manifest's `consumes` entry carries is discarded at registration. So emitting both versions delivers **both** to the same consumer. The invoicing engine's export event is meant to be consumed by an accounting connector. None consumes it yet (chapter 12), but once one does, a dual-emit is a double invoice in production, silently. The rule is not hypothetical either: the export event has been through exactly one version bump, and it was shipped as a replace. A replace fails loudly instead: a v1 consumer's strict parse rejects v2, and the event dead-letters where somebody sees it. Loud and stopped beats quiet and doubled. So no plan should promise a deprecation window that nothing can deliver. Routable dual-emit needs `(type, schemaVersion)` in the dispatch predicate plus a version dimension on the consumer registry — designed, filed, and postponed rather than abandoned. ## The event stream is the audit log Because every event carries tenant, scope, actor, entity and time — stamped, not supplied — the stream *is* the audit log. Complete by construction. "Who did what, when, to which entity" is a query, and the answer is the same data reporting runs on. Which is why reading one entity's history is a sanctioned projection over `_substrat_outbox` — rule 3 bans *writes* to the spine, not reads — and why `readTimeline` and `readHistory` exist rather than everyone writing their own `SELECT`. The same events also leave for a longer-lived store. The sweep drawn above ships each scope's unshipped events to the platform's Tier-2 event lake, where history across scopes and over long periods can be queried, and marks each one shipped only after the lake has confirmed it. A failure in between sends the same events again, which the event `id` makes harmless; marking first would risk a gap nothing could detect. Shipping removes nothing from the scope — the outbox still serves consumers, entity history, and each entity's version. --- **Next:** [Permissions and identity →](https://substrat.net/book/06-permissions-and-identity.md) --- # 6. Permissions and identity Source: https://substrat.net/book/06-permissions-and-identity.md Two questions that look like one and are not. **Authentication** asks who someone is. It happens at the edge, it involves an issuer, and Substrat has an opinion about none of it beyond the shape of the seam. **Authorization** asks what they may do. It is kernel-owned, it is enforcement input, and it is never delegated to an auth provider — because an authorization model that lives in your identity vendor is a model you cannot reason about, version, diff, or take with you. ## Authenticate: the neutral seam A vertical is an OIDC relying party. Sign-in is a redirect to an issuer and back; the vertical runs no credential store, holds no passwords, and implements no reset flow. What comes back is a `sub` — a subject identifier from the issuer. That is not yet anything. The per-tenant identity directory maps `sub` → `PrincipalId`, and a `sub` with no mapping is a person who has authenticated perfectly and may do **nothing**. The two halves are genuinely independent: proving who you are gets you no authority at all. Every demo vertical in the repo is OIDC-only, and the local development story is a real OIDC provider (`@substrat-run/dev-issuer`) whose only shortcut is that `/authorize` lists names instead of asking for a password. So the local login *is* the production round-trip, and changing issuer is a change of `OIDC_ISSUER` rather than a change of code. There is no dev-auth branch to diverge. [Authentication & identity](https://substrat.net/concepts/identity.md) is the detail — issuers, adapters, first-login sync, and the owner-claim flow for a freshly provisioned instance. ## Authorize: three authored things Humans, and agents under review, write exactly three kinds of thing. **Permission keys** — module-namespaced strings, declared in the manifest with human-readable descriptions: ``` workorder:create Create work orders workorder:report Start work, report time and material invoicing:export Export an invoice basis (makes it immutable) ``` **Roles at nodes** — a role bundles permissions; an assignment binds a principal to a role **at a node of the tenancy tree** (the tenant root, or one scope), inheriting downward. ```ts host.admin.assignRole({ principalId: tech, roleKey: 'technician', node: { tenantId, scopeId: stockholmBranch }, // scopeId: null = whole tenant }); ``` **Capability grants** — one permission, one node, optionally narrowed to **one entity** and its declared descendants, optionally time-boxed: ```ts host.admin.grant({ principalId: portalCustomer, permission: 'workorder:read', node: { tenantId, scopeId: branch }, entity: { entityType: 'facility', entityId: theirBuilding }, expiresAt: nextMonth, grantedBy: adminPrincipal, }); ``` Entity-narrowed grants are how a customer, a board member or a subcontractor sees only *their* things inside a shared scope. A grant can also target an organization, and members reach it through membership. That is the entire *administrative* surface, and there is no fourth kind of thing to author. What arrives later in this chapter is not a fourth kind but a fourth *author*: a user sharing one record from inside an operation, which mints a capability grant of exactly the shape above. ## How a check is answered **Diagram — a permission, from declaration to check.** 1. **Declared in TypeScript** (authored) — module manifests → keys + descriptions; roles → templates · entity grants → shapes. - Branches off to **pnpm lint:permissions** (checkpoint · a human reads this), which renders emits PERMISSIONS.md, the review artifact; CI --check fails the build on drift. This branch ends in a human review, not at runtime. 2. *substrat push* → **The deploy manifest** (ships) — the surface rides along as a registry; content-hashed → digests.permission. 3. *promote* → **Admission** (gate) — a real diff between two versions; a widened surface is visible, not implicit. 4. *at write time* → **Provisioning** (per tenant) — role templates projected into each; tenant's own _substrat_roles. 5. *every operation* → **ctx.check** (runtime) — reads scope-local tables only; absent or empty projection = deny. The branch off the top ends in a person: PERMISSIONS.md exists to be read in a diff, and CI going red is what makes the reading unskippable — it is not itself the approval. Internally the checker compiles those three things into relationship tuples (`subject → relation → object`) and evaluates with a **fixed four-rule algebra**: 1. **Role expansion** — principal has role, role carries permission. 2. **Tenancy-tree inheritance** — a permission at a node flows down to child scopes. 3. **Entity parent edges** — declared in manifests (`workorder → facility`) and written at runtime by `ctx.link`; entity-narrowed grants flow along them, depth-capped. 4. **Org membership** — grants to an organization reach its members. No negation. No configurable rewrite rules. Verticals never see or author tuples. The fixed algebra is the point. A policy language rich enough to express anything is rich enough to express a policy nobody can predict the behaviour of, and "why can this person see this?" stops having an answer. Four rules have an answer, always. ## The check does not leave the scope This is the design decision with the widest consequences, so it is worth stating plainly. Scope and entity tuples (rules 2 and 3) live in the scope's own database and evaluate inside its serialization domain. Tenant-level facts — role definitions, role assignments, tenant grants, org membership (rules 1 and 4) — are tenant-wide, so the control-plane directory is their authority. But **the checker never reads the directory on a request.** The control plane *projects* those facts into scope-local tables (`_substrat_roles`, `_substrat_tenant_tuples`) at **write** time, and the checker reads the projection exactly as it reads scope tuples. > The control plane is a **write-time authority that projects into scopes**. It is never a > read-time dependency on the request path. Cost moves from the read path (every request) to the write path (rare role and grant changes), which is the correct direction for a read-heavy multi-tenant system. And it is what makes a scope genuinely isolated: a vertical can run as its own Worker with **no control-plane binding at all**, which is the shape a customer-pushed vertical ships in. The consistency consequence is small and one-directional. Scope-level grants and roles stay synchronous and immediately consistent — the checker runs in the scope's own serialization domain, so there is no "did my write land" question. Only *tenant-level* changes are eventually consistent across a tenant's scopes, bounded by the write-time fan-out and a reconciliation sweep. And the failure direction is safe: **an absent or empty projection is a deny**, byte for byte the normal deny path. Missing data can only ever remove authority, never grant it. ## Revocation tombstones Access is withdrawn by **tombstoning** a tuple: it keeps its row, gains a `revokedAt`, and the checker's walk skips it. Nothing deletes a tuple, ever. A tuple that once granted access is the evidence of *why* an access was allowed. Deleting it destroys the audit trail exactly where it is most needed — a deleted row can show neither that access was revoked nor that it was ever granted. "Ever" means for as long as the scope does. Reaping is the one thing that takes a tuple away, because reaping takes the whole database the projection lives in; what outlives it is the admin log, which is never swept (chapter 11). Liveness is therefore one predicate applied identically everywhere: a tuple grants only while it is **unexpired and unrevoked**. Expiry and revocation are siblings, not separate mechanisms. ## Decisions carry proof ```ts type Decision = | { allowed: true; proof: RelationTuple[] } // the chain that granted access | { allowed: false; checked: PermissionKey; node: Node }; ``` An allow **always** carries the chain that produced it. An unexplained allow is unrepresentable — which is a strong statement, and it is what makes three features possible rather than aspirational: **explain** ("why does this user see this?"), **view-as-user** (render any screen as any principal, with real decisions), and the human-readable **permission diff** that the merge checkpoint reads. The proof also travels. When an operation emits, the kernel stamps onto the envelope which permissions it checked and passed, and — when the allow came through a capability grant rather than a role — a reference to the granting tuple. `explain` can re-derive the chain later, but it cannot recover *which* grant was consulted at write time once the tuples have moved since. That pointer is what the envelope keeps, so a mutation carries its own proof of authority. ## Denials are recorded An allow leaves its trace on the event it authorized. A denial has no event to ride, so it is captured on its own: a row in the scope-local `_substrat_denials` table with the actor, the permission, the node, the operation, the impersonation session, and the time. This is the one moment where an actor's intent and the permission model visibly disagree, and no other log witnesses it — the admin log records changes, the outbox records *allowed* mutations. The mechanical detail from chapter 3 matters here: because a denial rolls its operation back, the row is written **after** the rollback, as its own write. Only an *enforced* denial is recorded — one that went through `assertAllowed` and therefore carries the checked key and node. A module's own hand-thrown `PermissionDenied` is its policy, carries no key, and is left alone. ## Sharing at runtime Everything above is administrative. The same capability grant has a second author, and it is the user rather than the admin: **`ctx.grant` and `ctx.revoke`**, called from inside an operation. ```ts await ctx.grant(theirPrincipal, 'todo:read', { entityType: 'list', entityId: listId }); await ctx.revoke(theirPrincipal, 'todo:read', { entityType: 'list', entityId: listId }); ``` Both are entity-required and **delegating** — the caller's own decision on that entity is re-checked, so you cannot share what you cannot see. Both are transactional with the operation: if it rolls back, so does the share. This is the primitive for "share this record with a person", and neither alternative is it. A `ctx.link` edge is permanent and not revocable at all. Org membership is revocable but coarse — a whole org, not one record — and minting an org per domain row to obtain a revoke is a known anti-pattern with an issue number attached to it. `demos/todo/src/module.ts` shows both in one line each, and is worth reading before you design any sharing feature. ## The permission checkpoint New permission keys and role definitions are one of the two things an agent may never self-approve. The mechanism: each vertical exports `MODULES` and `ROLES` from its seed, `pnpm lint:permissions` renders `demos/*/PERMISSIONS.md` from those same objects, the file is checked in, and CI re-emits with `--check`. So a widened role cannot merge without appearing in the PR diff as a table of key → description → which roles hold it. CI going red is what makes the reading unskippable; it is not itself the approval. A human reads the diff. --- **Next:** [Engines, verticals, composition →](https://substrat.net/book/07-engines-and-verticals.md) --- # 7. Engines, verticals, composition Source: https://substrat.net/book/07-engines-and-verticals.md Chapter 1 named the three layers. This chapter is the middle and top ones in working detail: what belongs in an engine, what belongs in a vertical, and the rules that keep seven engines from becoming forty-two pairwise integrations. ## An engine is an invariant with no vocabulary There are seven: work orders, invoicing, booking, protocols, invites, metering, absence. Each one is headless. No screens, no words a user reads, no opinion about what you call things. What an engine owns is the set of statements that must never be false: - a completed work order cannot return to scheduled; - an exported invoice basis is immutable afterwards; - a protocol entry is append-only; - every mutation emits an event; - every operation checks a permission. The test for whether something belongs in an engine is not "is it reusable". It is: **would it be a bug in any vertical for this to be violated?** Pricing is not that — one customer rounds differently. State transitions are. ## A vertical is everything a person touches Vocabulary, roles, pricing, screens, workflow, the shape of the API its own app calls. A vertical composes engines the way an application composes libraries, except the library's invariants are enforced from below rather than trusted. The repo carries eight demo verticals. Two of them are the reference implementations. They answer different questions, and they are references *because they are used*. A demo nobody runs rots, however complete it looks. - **`demos/todo`: the shape.** A record app built forward from its declared model, with per-list sharing by email, revoke, and a React app that tells a 403 from an empty list. It has **no engine at all**. Not every vertical needs one, and pretending otherwise leads to engines that exist to be composed rather than to hold an invariant. It is what a scaffolded project's docs point at. - **`demos/ticket0`: a deployed vertical.** A support desk that runs in production. It composes the metering engine by call, runs sandbox-clean on the hosted runtime, sends and receives mail through a relay, answers with a model around its operations (chapter 4), and ships through a CI push. It is the one to read for how a real vertical meets the platform. ## Star topology: engines never call each other **Diagram — the line.** Two bands either side of one boundary. - **What an agent writes** — mistakes are cosmetic: screens, forms, workflows, reports, pricing, vocabulary. - **the line.** Above: AI velocity — mistakes are cosmetic (a wrong screen). Below: humans + runtime guarantees — mistakes are catastrophic (a tenant leak). - **What the substrate owns** — mistakes are catastrophic: tenancy, auth, migrations, integrations, audit, compliance. The layer where models are weakest is the layer where mistakes are fatal. So the split is structural rather than instructional: prompting a model to "be careful with tenancy" is a suggestion, and a `getScope` call that fails closed on a mismatched pair is a fact. No engine imports a sibling. None. Composition happens through three kernel-mediated channels: 1. **Opaque refs** — an attachment contract binds to `(entityType, entityId)` without knowing what the entity is. 2. **Events** — an engine reacts to another's schema-versioned events. The invoicing engine consumes `workorder.completed` *and* `commerce.order-placed` — two different domains — without importing a single type from either producer. 3. **Vertical-owned orchestration** — a synchronous flow needing two engines is wired in the vertical, where the glue is visible and editable. The arithmetic is the argument: compatibility stays at *N* kernel contracts instead of *N²* engine pairs, and each engine versions independently. There is a corollary test worth remembering: **if two engines need chatty synchronous communication, they are one engine drawn wrong.** That is why "work orders + time reporting" is one engine rather than two that call each other constantly. And if a vertical finds it must *fork* an engine, the engine drew its line wrong. That is a bug report about the engine, not a fork to maintain. ## By call, or by event — and it is a fact about the exports An engine is composed in one of two modes, and which one it is determines its whole shape. ### By call (work orders, protocols, booking, metering) Operations are thin: the permission check, plus one exported in-scope function. All the logic lives in composable exports, so a vertical wraps them inside its **own** transaction: ```ts // in the vertical's operation, same transaction, own permission check assertAllowed(await ctx.check('fsm:complete-job')); await ctx.atomic(() => completeWorkOrder(ctx, { orderId, billable })); await ctx.sql.exec('UPDATE fsm_jobs SET closed_at = ? WHERE order_id = ?', [ctx.now(), orderId]); ``` One transaction, both writes, atomic. The vertical extends by composition and never by forking. Note the `ctx.atomic` from chapter 4 — it is what makes catching that engine call safe. ### By event (invoicing) The vertical **emits**, the engine consumes, and the vertical reads results back through the engine's own operations or by consuming its events into a side table keyed by the engine's id. There are deliberately **no in-scope exports**. That is not an omission — the engine being the only writer of its rows is what keeps immutable-after-export safe from a half-finished caller. If a vertical could call in mid-flow, the invariant would depend on the caller finishing. Which mode an engine is, is a fact about its exports. State it in the engine's header, so an absence reads as intent rather than as something nobody got round to. ## Another module's tables are private Not "please don't" — a linted boundary with a reviewable escape hatch. Engine data is reached through the engine's exported in-scope functions. The stable surface is entity ids, `EntityRef`s, and event payloads. A vertical needing extra data on an engine entity adds its **own side table keyed by the engine's id**, and never a column upstream. One-time extraction handoffs — a genuine migration where the data must move once — use an explicit `boundary-lint-allow R5` … `boundary-lint-end R5` block. That is a reviewable escape hatch rather than an absent rule, which is the right shape: rules with no hatch grow workarounds that are worse than the hatch. ## Engine surfaces evolve additively Three rules, and one of them has teeth that surprise people. **New operation inputs are optional, with behavior-preserving defaults.** Adding a required input is a breaking change to every existing caller. **Permission keys are never renamed.** A rename silently removes authority from everyone who held the old key. **Emitted event payload fields are frozen once shipped.** Rename, remove or retype means a `schemaVersion` bump — and chapter 5 explains why a bump is a *replace* with no dual-emit window available. ### The gate that makes a break red There is one call site of every engine that used to be invisible to the compiler: the scaffold template. It imports the engines, so it is a real call site — and nothing compiled it, so a non-additive change could merge, release, and reach `npm create substrat` broken. That is now closed. `tools/template-sync.mjs` materializes the template into `packages/template-check`, a workspace member holding `workspace:*` links, so `typecheck`, `test` and `boundary-lint` all reach it with no new command to remember. A non-additive engine surface is now red **in its own PR**, against the workspace. There is a second, separate gate — `pnpm lint:scaffold` — that runs the same scaffold against the **published registry**, post-release and weekly, never on a PR. The distinction is the whole point: between a merge that adds a surface and the release that publishes it, the template legitimately runs ahead of npm. Being ahead of the registry is a pass on the PR gate and a legitimate red on the release one. ## The runtime half: parse on the way out A type-checked seam is not enough, because a vertical compiled against engine 0.3 and *running* against 0.4 has no compiler in the room. So a value crossing the engine seam is `.parse`d by the schema the engine publishes — on the way **out** as well as in. And a read names its columns rather than using `SELECT *`, which would pin the published shape to whatever the physical table currently holds. Without this, the failure is not a throw. It is a field that moved, read as a different field, rendering **wrong data on a screen**. Silently. That is why the parse is unconditional including on bulk reads: dev-only validation is absent exactly where version skew lives. Two helpers in `@substrat-run/contracts` do the work — `returns(schema, surface, value)` and `columnsOf(schema)` — and an engine binds them to its own name in one line (`engineSeam('engine-workorder')`). Every engine is converted, each with a `test/seam.test.ts` that moves its tables underneath it and asserts a throw rather than wrong data. The `SELECT *` half is mechanical: boundary-lint **R8** fires on a star read anywhere in an engine's module code, with the same reviewable allow-block hatch for a maintenance read whose row never leaves the engine. The `returns()` half is still convention — proving a row-returning export is parsed needs a type checker the linter deliberately does not carry — so a new engine is expected to do that half itself. Said plainly rather than implied. ## Declaring, not writing A vertical increasingly does not hand-write its manifest. It declares entities, operations and permissions once in a typed module, and the manifest's entity fragments, permission list, event list and DDL are **derived** from that declaration. The compiler checks the joins: a parent naming no entity, an `entityIdFrom` naming no output field, a payload carrying an `erasable` field are compile errors rather than runtime surprises. `pnpm lint:model --check` gates the emitted `model.json` the way `lint:permissions` gates the permission surface. The browser client is generated from the same declaration — entity interfaces, paths, methods, request bodies, the paged link walk. What stays hand-written beside it is only what the model does not declare: which principal a request is made as, and the error envelope the vertical chose in its own `app.onError`. [The model](https://substrat.net/concepts/model.md) is that story in full, and it is the page to read before building anything. --- **Next:** [The life of one deploy →](https://substrat.net/book/08-one-deploy.md) --- # 8. The life of one deploy Source: https://substrat.net/book/08-one-deploy.md A change is committed. This chapter follows it from a laptop to a scope's database. The decision that shapes everything here is that **uploading code and serving it are two acts, deliberately not fused.** ## Push uploads a version. It serves nothing. ```sh substrat push ``` The CLI builds your worker locally, then POSTs the bundle plus a manifest to the control plane, which records an **immutable version**: a `deploymentRef`, a permission digest, a migration digest. That is all. Nothing is serving the new code. A push is cheap and reversible — it adds a row to a registry. The reason for the split is that "here is a new build" and "run this build against real data" are different decisions, and a mistake in the second costs far more than a mistake in the first, and fusing them means you can only ever make both at once. Before the upload, the CLI runs the layer rules on the **source tree** — the same `boundary-lint` that guards this repo, now shipped as a package — plus a permission preflight that derives your declared surface and refuses a drifted one. Both run before the wrangler build, because a refusal is worth more in a second than at the end of a build that was going to ship broken. The push carries more than code. It flattens what each module **declares**, including the events it emits and consumes, its schedules and its freshness expectations, onto the version's manifest. That is what lets the dashboard later draw declared behaviour against observed behaviour (chapter 10). It also refuses a manifest that declares a binding in the `SUBSTRAT_*` namespace, because the platform injects names there, such as the version id every event is stamped with, and a vertical must not be able to shadow them. That gate is newer than it sounds, and the gap it closed is worth knowing. Until it existed, the mechanical rules this book has been describing ran **only in this repo's CI**. A vertical developed anywhere else was built, uploaded and admitted having been checked by nothing, which made the rules advisory for every real customer — the opposite of the claim made for them. There is an honest bound on the fix, too: this runs on the builder's own machine, not platform-side over the uploaded bundle, and `--skip-lint` exists. The flag says out loud that the push was ungated, on the reasoning that a flag which silently weakens a gate becomes the default in somebody's CI. ## Admission: may this code run here at all? Before a version can be promoted, it must be **admitted**, and admission is answered **mechanically** by the sandbox contract. The bundle may declare only its own durable stores — a positive binding allowlist. No `CONTROL_PLANE`. No platform secret. It runs inside a Workers-for-Platforms isolate, held to quotas. Satisfy the contract and nothing else is in question. The allowlist deliberately excludes egress-shaped bindings (`send_email`, `ai`, `browser`). **Reaching the outside world is not a binding — it is a granted capability.** A vertical that needs one *declares a request* in its manifest (`substrat.sendsEmail`, `substrat.usesModels`, `substrat.provisions`), and something outside the bundle turns it on. The request is refreshed on every push and grants nothing by itself; no push can set the flag it needs. What comes back differs by capability, and the difference is worth knowing: - **A relay**, for a capability that rides a credential. Email is the reference: the vertical POSTs to the control plane — the one worker holding an outbound-mail credential — which sends on its behalf and re-checks the grant on every call. The vertical holds no credential and reaches no third party directly. - **A real binding**, for models. `substrat.usesModels` is answered with an `ai` binding appended *after* the allowlist check has run on the declared set, so a vertical still cannot declare it for itself — only be handed one. It takes both halves: the platform willing to bind at all, and this version having asked. Who must **vouch** for a version depends on who will be exposed to it: - **A private vertical** — you own it, nobody else can install it — lands **admitted automatically**. There is no third party to protect, so the sandbox contract is the whole gate. An auto-admission note records that no human vouched, so the platform can still tell the two apart. - **A listed vertical** — published, so *other* tenants run it against *their* data — raises a second question that no contract can answer, and its pushes land **pending** for staff admission. The human gate did not disappear; it moved to the boundary where it means something. **Publishing is the checkpoint, not every push.** ## One channel, called `prod` A vertical has exactly one channel. There is no `dev` and no `staging`. Those existed once and were write-only — nothing ever read or served them. The reasoning is worth keeping: a channel names a *pointer at code*, and an immutable version id already does that. What a real non-production environment needs is not a second pointer but a second **scope with data**, which is a preview. A promotion re-points `prod` at a version. It is a **rebind, not a rename**: the same scope, the same data, serving new code. Two things a promotion always respects: **The surface checkpoint.** A promotion whose permission or migration digest differs from what is live is **refused** until the diff is acknowledged. This is the same two-checkpoint discipline the kernel applies to modules, applied at the deploy boundary, with the owner as the human at their own checkpoint. **Channel history.** Every promotion appends a row: what went live, what it replaced, who did it, exactly when. That history is the dashboard's rollback picker, and each timestamp is an instant a point-in-time rewind could restore data to. ## In place: the property everything else depends on A version update is **not** a rebind to fresh, empty storage. A promote re-uploads the bundle onto the vertical's one stable serving script; the scope's Durable Object and its SQLite **stay put**. Which means: - **Data carries forward.** Nothing moves. - **Migrations run forward against production data**, exactly as the migration model always assumed. Before in-place serving existed, that mechanism had never actually run in production. - **Secrets survive.** And migrations do not run in a fleet-wide deploy step. Each scope migrates **on wake** — the `migrateAndRecord` call from chapter 3 — inside its own serialization domain, the first time anyone touches it after the version moved. A thousand scopes do not migrate at once; each migrates when it is next used, and a scope that fails to migrate fails closed and serves nothing, which is what stops it from rendering as healthy. Stragglers are not left to chance: the platform sweep reconciles migrations as its first phase, walks scopes that are behind the frontier, and flags the ones that keep failing. Chapter 9 is that pass. ### A promote repairs its own installs Each scope records the version its provision hook last ran against. Once a scope is bound to a new version — by a promote or a per-scope bind, never by an upload alone — the platform sweep re-runs `/internal/reconcile` on every active install whose receipt is behind. So whatever a new release's `onProvision` mints reaches existing installs too. Which is exactly why **that hook must be idempotent**: it runs more than once over a scope's life, and a hook that assumes it runs at provision time only will do the wrong thing the first time a release changes what it mints. A version is badged **code-only** or **schema-change** at publish, so you know before you promote whether the schema is involved. ## Previews: a fork of the data, bound to the new version Before a promotion whose migrations changed, you want to see the new version run against real-shaped data without touching production. Substrat can do this because the scope-host contract already runs identical module code on two adapters. A **preview** is a **fork** of a scope's data bound to the new version, reachable at its own URL. The governing law is that **migrations are forward-only**. A snapshot taken today, at production's frontier, bound to today's or a later version, rolls its migrations forward on the copy. Rehearse, throw the fork away if it breaks, and production never saw it. The inverse is invalid: pointing *old* code at *today's* data across a schema change cannot work, because you cannot un-run a migration. So the rollback strategy is **fork-before-you-promote** — snapshot right before binding the new version, and a bad version leaves the prior one still runnable at its correct frontier. A fork is a **governed dead end**. The export copies the kernel spine too, so no connector, no schedule and no billing consumes from a preview — a test copy cannot send a real invoice. Preview URLs are non-public: gated code running against real-shaped data. ## Backout Because every app is one scope with its own database, recovery is a per-tenant primitive rather than an environment-wide runbook. One tenant's rewind has a clean, self-contained blast radius. **PITR rewind — the first-hours backout.** Right before an upgrade migrates, the scope bookmarks the instant (Durable Object point-in-time recovery, roughly 30 days of history). If a promotion goes wrong, an audited rewind rolls that scope's data back to the bookmark, time-boxed to about 24 hours unless forced. It is a *destructive, in-place* rollback of one DO — the opposite lever to a fork, which is a non-destructive copy. It rewinds the whole database, so a stale bookmark is refused rather than silently skipped. One sharp edge, stated because it will bite otherwise: **data and version binding live in different DOs.** Rewinding *past a bad migration* means also rebinding the version — two coordinated rewinds, with no atomicity between them. **Backup / restore — the considered path.** `substrat scope restore` loads a backup into an existing hosted scope, replacing its data: a pulled `.sqlite`, a local scope file, or a dump. This is the deliberate recovery when a time-boxed rewind is not the right tool. ## The whole shape Push uploads a version. Admission asks whether it may run here, mechanically when you are the only tenant exposed and with a human when you list it. Promotion points the one channel and serves **in place**, so data carries forward and migrations run forward against it, each scope on wake. Previews rehearse a risky version against a fork. PITR rewind and backup/restore are the backout. The staff gate lives at **publish** — the moment other tenants can run your code against their data — and nowhere else in the loop. --- **Next:** [The two clocks →](https://substrat.net/book/09-the-two-clocks.md) --- # 9. The two clocks Source: https://substrat.net/book/09-the-two-clocks.md Everything so far has been driven by a request. This chapter is the work nothing asks for: retries, recurring jobs, reconciliation, reaping. There are **two** background clocks, they run in different places, they do different work, and the reference describes them in two pages that never sit next to each other. Getting them straight is most of understanding how Substrat behaves when nobody is looking. ## Why an alarm and not a cron Both clocks ship as Durable Object **alarms**. Three reasons, and the first one is decisive: **A dispatch-namespace script gets no crons.** A hosted vertical pushed into Workers-for-Platforms does not honour `triggers.crons`. A DO alarm is the only timer such a deployment can own. Everything else follows from needing a mechanism that works there. **No overlap.** A cron fires on a schedule regardless of whether the last run finished, so two passes overlap the moment one runs long. Here the next alarm is set **only after the current pass settles** — so the interval is a **gap between passes, not a rate**. A slow pass delays the next one rather than racing it. **No configuration.** An alarm self-arms from code. There is no `wrangler.toml` entry to forget in one environment and remember in another. The first reason is also the limit of the argument, and it is worth being exact about which clock it binds. Clock one runs inside a dispatched vertical, so the alarm is the only timer available to it and there is no choice to make. Clock two runs on the platform's own deployment, which is an ordinary worker and *can* hold a cron — and the hosted control plane uses one, firing the same pass every fifteen minutes. So the no-overlap property above is the alarm's, not the pass's: on the cron path a long pass can still meet the next tick, and it is the pass itself that has to tolerate that. And the loop is built never to die. A pass that throws whole is still caught, still reported through `onPass`, and the alarm is **still re-armed** — the alternative being workerd's own alarm retry, which would tighten the interval rather than keep it. Arming is idempotent. `ensureArmed()` does not move an alarm that is already set, so it is safe and cheap to call on every request, which is exactly how it is kept alive. Where a cron *is* available, pointing `scheduled()` at `ensureArmed()` makes the cron a safety net that recovers a lost alarm, never a second thing that runs passes. ## Clock one: the scope sweeper **Where:** inside the vertical's own deployment, where its modules and its scopes' data are. **Singleton name:** `scope-sweeper`. **What it holds:** a roster in its own storage — `scope:` → `tenantId`. This is the clock a vertical cannot do without, and the one most people mean when they ask "what retries my failed delivery?". One pass walks the roster, eight scopes at a time by default, and does two things per scope: **`drainDue(tenantId, scopeId)`** — the executor retry driver from chapter 5. This is the outbox's backstop: connector deliveries and executor effects that failed and are now past their `next_attempt_at`. The pass returns `attempted` / `delivered` / `retrying` / `deadLettered` summed across the roster. **`runDueSchedules(...)`** — the recurring work a module declared in its manifest. Two properties make that safe to run unattended: - It runs under a **system actor**. Emitted events read as `{ system: '@your/module' }`, not as whichever admin last touched the data. Modelling a nightly job as a signed-in user is precisely the audit-trail laundering this exists to avoid. - **`ctx.check` is still the only gate.** A schedule's declared permissions are granted to the module's system principal at provision, so the operation's own `assertAllowed` resolves the same way it does for any caller. A schedule can do exactly what it declared and no more — and revoking that grant for one tenant turns the schedule off for them, with no special "disabled" flag, because the operation simply fails its own check closed. `cadence` is a **floor, not a promise**: a schedule fires no more often than `everyMinutes`, and the sweep is what actually runs it, so sub-sweep cadences round up. ### The roster is maintained by the routes, not a directory The sweeper does not enumerate anything. `/internal/provision` and `/internal/reconcile` call `noteScope`, `/internal/delete-scope` calls `forgetScope`. That is what lets a **CP-less vertical** — one with no control-plane binding at all — have recurring work despite having no directory to read. It knows its scopes because it was told. And the alarm follows the roster: **it lapses on an empty roster**, and `noteScope` restarts the loop when the first scope arrives. A vertical with no scopes burns no alarms. ## Clock two: the platform sweeper **Where:** the platform's own deployment. **What fires it:** either trigger, over the same `runPlatformSweep`. The adapter ships `definePlatformSweeperDO` — singleton `platform-sweeper`, the alarm loop clock one uses — for a deployment that wants one. The hosted control plane instead points its `scheduled()` handler straight at the pass, on a fifteen-minute cron. **What it holds:** nothing — it reads the directory. This is the fleet-wide maintenance pass. One pass, in this order, each phase recording per-unit outcomes and **stepping over** failures rather than letting one sink the pass. Every unit of work also leaves a durable **sweep run** record, which is how "this schedule never ran" becomes a visible verdict rather than an absence (chapter 10): **1. Migration reconciliation.** First, deliberately. The drain phase below wakes scopes, and waking migrates lazily — so running this after it would count every failed scope's attempt twice per pass. It walks scopes behind the deployed frontier, retries them with backoff, and flags the ones that keep failing. Scopes it leaves in a failed state are **skipped by every later phase**: they fail closed, so touching them would only re-throw the same migration error as noise. **2. Executor drain.** `drainDue` over every active scope — the same call the scope sweeper makes, here for deployments the platform drives. **3. Platform-intent drain.** Pull and execute each active scope's pending intents from chapter 5. Injected, because the kernel cannot reach a vertical's DO — the control plane supplies a function that goes over the vertical's `/internal` surface. **4. Provision reconcile.** The phase chapter 8 described: re-run `/internal/reconcile` on every active install whose provision receipt is behind the version it now serves. **5. Recurring schedules.** For each vertical that declares them, enumerate live scopes and invoke each due operation. A fork or a snapshot is **skipped**, so a test copy never runs real recurring side effects. **6. Freshness expectations.** Record a verdict on event types that were expected to have been seen recently — the "nothing ran" signal, which is otherwise invisible because absence produces no row anywhere. **7. Snapshot GC.** Delete expired forks. A preview with a TTL disappears here. **8. Reap long-archived scopes.** See below. **9. Reap tenants past their grace window.** After the scope reap, so a tenant's scopes are gone before its row becomes a tombstone. **10. Event drain to the lake.** Ship each active scope's undrained outbox rows to the event sink (Tier 2, chapter 11), up to 200 per scope per pass, then stamp them drained. With no sink bound, the phase does not run and nothing is marked. Draining copies events and never prunes the outbox. **11. Access-log drain and prune.** Ship staff access rows to durable storage, stamp them drained, then prune what was shipped. The sink must be **durable before it resolves** — everything downstream treats a resolved ship as proof the evidence survives outside the directory, so a sink that buffers and returns early turns a retention policy into data loss. ## Reaping: the part that is not automatic This is the mechanism the reference mentions in a single clause, and it deserves the space. **Cloudflare never garbage-collects a Durable Object.** An archived scope's bytes persist forever unless something explicitly wipes them. There is no platform-level cleanup to inherit, and "we archived it" is not the same sentence as "it is gone". So the platform sweep can do it — and it is **opt-in**: ```ts runPlatformSweep(host, { reapArchivedAfterDays: 90, // UNSET ⇒ the phase is skipped entirely reapDeletingAfterDays: 30, // same, for tenants }); ``` Unset, the phase does not run. The reap is irreversible, so a deployment must **name a retention window** before the sweep will ever destroy anything. The default is to keep the bytes and let a human decide. Three details you will meet in practice: - A scope with a **null `archivedAt`** — archived before that column shipped — has no knowable age and is **never** auto-reaped. It must be reaped by hand. - `reapScope` re-checks the `archived` status below the seam, so a row that changed underneath fails closed there rather than being reaped on stale information. - The retention reap **forces past the bound-hostname guard**. That guard exists to stop an interactive per-scope mistake; a retention policy on an already-archived scope is a deliberate decision, not a slip. The tenant reap composes the same seam: for each of the tenant's non-reaped scopes, archive if needed, then reap, then clear the tenant's PII and config directory rows. The tenant row survives as a **tombstone** — audit history, and a burned slug — and the admin log is kept whole, because it is the compliance witness and is never swept. What ends up gone: the storage. What remains: the fact that it existed, who ended it, and when. ## Node, for completeness Self-hosting on Node, neither DO exists. A server calls `startPlatformSweeper` at boot and gets the same pass on an interval. The unit of work is identical — `runPlatformSweep` is kernel code that holds no timer of its own. Only the thing that fires it differs. That separation is why the sweep is testable at all: a test calls `runPlatformSweep` directly and asserts the report, with no clock involved. ## Reading the result Both sweepers return a report rather than logging and forgetting. `retrying` and `deadLettered` are the numbers a health surface should show; `errors` is per-unit, so one failed connector reconciliation does not hide behind a green pass. A caller that ignores those learns nothing, which is exactly the failure mode the older silent drain had, and it is why the reports exist in the shape they do. The next chapter is about the reading. --- **Next:** [Seeing what happened →](https://substrat.net/book/10-seeing-what-happened.md) --- # 10. Seeing what happened Source: https://substrat.net/book/10-seeing-what-happened.md The last chapter ended on reports that background work returns so that something can read them. This chapter is about the reading. It covers what the platform records about a running vertical, where each record lives, and which question each one can answer. There are four instruments, at four different grains, and the most common mistake is to ask one of them a question only another can answer: | Instrument | Grain | Exact or sampled | Answers | |---|---|---|---| | **Traffic** | one datapoint per request, at the router | sampled | how much, how fast, how many errors | | **Invocation logs** | one line per request, written by the vertical | exact while retained | what this request did, and what it printed | | **Traces** | spans the runtime produces | sampled, opt-in | where outbound time went | | **The spine** | one row per mutation, per delivery, per denial | exact, forever | what happened to the data, who did it, and what it set off | The first three are about *requests*. The fourth is about *meaning*, and it is the one that cannot be rebuilt from anything else. ## Traffic: the router counts Every request passes the router (chapter 3), so that is where it is counted. Once dispatch returns, in a `finally` so errors count too, the router writes one Analytics Engine datapoint: ``` index: [tenantId] blobs: [verticalSlug, scopeId, surface, statusClass, rayId] doubles: [durationMs, status] ``` The index is the tenant, so every read of this dataset is a read of one tenant. The dashboard's chart is a sampling-weighted `sum(_sample_interval)` with weighted quantiles for latency, run through the Analytics Engine SQL API. **The data is sampled.** It is the right instrument for "is this app getting slower", and the wrong one for anything that has to be exact, such as an invoice (chapter 12) or proof that a particular request happened. A sampled count is an estimate, and it presents itself as one. ## Logs: the vertical writes its own line This is the instrument with the least obvious design, and the reason for it is a platform fact. Workers observability is keyed on the **worker script**. A vertical's script serves *every* tenant that installed it. And a trace does not cross the router's dispatch hop into the vertical. So the router knows the tenant and cannot label the vertical's log lines with it, and the vertical's log lines, unlabelled, belong to nobody in particular. A tenant's Observability page would be either empty or someone else's. So the vertical writes the line itself. One middleware, mounted first: ```ts app.use('*', invocationLog({ routerSecret: (env) => env.ROUTER_SECRET })); ``` It writes one JSON line per request to Workers Logs, in a `finally`, so logging can never fail the request: ``` substrat: 'invocation' tenantId, scopeId, vertical, surface method, path // the path only; the query string is dropped, so no OIDC code is logged invocationId // a ULID minted for this request status, threw, durationMs ``` Workers Logs indexes JSON fields, so `tenantId` becomes a filter. Reading a tenant's logs takes two phases. The first finds that tenant's invocation lines. The second uses Cloudflare's own per-request `$metadata.requestId` to fetch every other line the same request printed, including a stack trace from code that has never heard of tenants. Three details come from getting this wrong once: - **The tenant comes from `readRoutedNode`, never from a header read directly.** A forged stamp would put text the forger chose onto another tenant's dashboard. So `routerSecret` is required: without it every assertion fails verification and **nothing is written**. From the outside that looks the same as no traffic, which is why a bare `invocationLog()` is refused. - **It must be the first registration.** Hono composes handlers in registration order, so a mount below some routes logs part of the surface and stays silent for the rest, which reads as "no traffic on those routes". `lint:invocation-log` checks the *order*, not just that the mount exists. The scaffold template ships with the mount in place. - **It is not retroactive.** Only versions pushed after a vertical adopted it write lines. ## Traces: opt-in, and narrower than the word suggests Automatic tracing is set per push, from a sampling rate the control plane holds. Today that rate is on in the test environment and off in production. Nothing in the platform emits a span per operation, permission check or engine call. What reaches the trace data is what the runtime generates on its own, mainly outbound `fetch` and the Durable Object entry hop. The platform reads it for one purpose: comparing a version's *observed* egress with its *declared* allowlist (chapter 8). A per-operation waterfall is designed and not built. That is part of why the next section matters. ## The spine, read as an instrument Chapter 5 described the outbox as the audit log. It is also the best observability data the platform has. Every row is exact, typed, stamped by the kernel, and kept. The kernel ships **sanctioned reads** over it, and the dashboard puts each one behind a button, so nobody has to write SQL against the spine to use it. **History.** `readHistory` returns one entity's events, newest first, with the fields a hand-rolled `SELECT` would misread: the authorization chain, the impersonation stamp, the operation, the deployed version. In the dashboard, opening a record under an app's **Data** tab shows this timeline. **Why?** Every event emitted while a delivery is in flight records `causedBy`, the id of the event whose consumer emitted it. `walkEventCause` follows that link backwards. It says how the walk ended (complete, cut, depth limit, or a missing link), so a short chain is never mistaken for a complete one. **What did it do?** `walkEventEffects` goes forward. It builds a tree from the delivery journal: which consumers an event reached, whether each one handled it, is retrying, or gave up, and how many attempts it took. This is where `_substrat_deliveries` shows up in the dashboard. It is not a raw table of deliveries but the delivery rows hanging off the event that caused them. **Same call.** This joins a data change to a request. The vertical host mints the `invocationId` that the log line carries, passes it into `invoke`, and the kernel stamps it on every event the call emits, including events that consumers emit in that call's post-commit tail. `readInvocation` returns everything one call did. The same id is on the log line, so a record's call can be matched to the request that made it. That match is by hand today: the Logs view does not yet filter by invocation id. That join has limits, and they are exact: - It exists only for operations reached over the vertical host's HTTP operation route. Seeds, internal calls and events from before the column existed carry `null`. - The router's datapoint carries Cloudflare's `rayId`, not the `invocationId`, so traffic does not join to events. - **Denials and deliveries carry no invocation id.** A delivery joins through its event, and a denial joins to nothing but its time and actor. **Facets.** `facetEvents` is a small group-and-count over one scope's outbox. It filters by type and time window, groups by type, actor, operation, version, entity type, PII class, invocation, or one payload field, and returns counts per bucket. Each bucket carries `lastSeen`, the time of its latest event. That tells a path that **stopped** (it has old events) from a path that **never ran** (it has none). Erased payloads are counted separately rather than folded into a "null" bucket. This is the **Events** explorer. **Flow.** `substrat push` records what each module *declares* it emits and consumes. The dashboard compares that with what the outbox *shows* happening. Triggers lead to modules, modules to event types, event types to the outside world, and anything declared but never observed is drawn dashed. Next to it, per-operation health joins emitted events with the denial log, grouped by operation. That is where a role missing a permission key shows up before a user reports it. ## Absence is a signal too Everything above records things that happened. The hardest failure to see is a job that never ran, because that failure leaves no row anywhere. Two mechanisms produce the row: - **Freshness expectations.** A manifest can declare `freshness: [{ eventType, within: { hours } }]`. The platform sweep (chapter 9) checks each one and records a verdict. "No `invoice.sent` in 26 hours" becomes a fact instead of a hunch. - **Sweep runs.** Every unit of sweep work is recorded, whether it was a schedule, a freshness check or a connector poll. The Schedules panel turns those records into four verdicts: *On schedule*, *Overdue*, *Never run*, and *No sweep data*. The last two are deliberately different. Module code cannot forge these rows: `ctx.requestPlatform` refuses the kind the platform uses to write them. ## Where each one surfaces For a customer's admin, in the **Dashboard**: - **Observability** (a team page, filtered by app): *Traffic* (the router's data), *Health* (worst first), *Logs* (invocation lines), *Events* (facets), *Schedules* (sweep runs), and *Flow* (declared versus observed, plus per-operation health). - An app's **Data** tab: a record's history, with *Why?*, *What did it do?* and *Same call* on each event. - An app's **Settings → Integrations**: the platform-request journal, meaning what the platform did with each intent the app raised. - An app's **Overview**: a status band, a traffic sparkline, and release markers on the chart. For Substrat operators, in the **Console**: the fleet views, plus *Operations → Failures, Issues and Sweeps*. Failures are grouped into issues by fingerprint, so a thousand identical errors appear as one row with a count. One property holds across all of them. **Every read that reaches into a scope on a tenant's behalf leaves an access-log row** that names what was read. Looking at data is itself recorded, which is where the next chapter starts. ## What this does not give you - **A raw outbox or delivery-journal browser.** Events are reached through facets and through records, and deliveries through the event that caused them. The spine's tables appear under *System* in the table browser, but there is no dead-letter list across a scope. Finding every consumer that gave up this week is a query nobody has built into a view. - **Exact request counts.** Traffic is sampled. - **Cross-scope event questions.** Every read above is one scope. "Across all forty branches" is forty reads, or it is the lake. --- **Next:** [The audit trail and the lake →](https://substrat.net/book/11-audit-and-the-lake.md) --- # 11. The audit trail and the lake Source: https://substrat.net/book/11-audit-and-the-lake.md "Audit log" is one phrase for several records, kept in different places, by different code, under different retention. Asking which one is *the* audit log is how a compliance question gets a confident wrong answer. So this chapter names each witness, then follows the one that leaves the scope: the event stream, drained into a table that SQL can query across the whole fleet. ## Four witnesses, and what each one saw | Record | Lives in | Written by | Witnesses | Kept | |---|---|---|---|---| | **The outbox** | each scope | the kernel, inside the operation's transaction | every *allowed* mutation, with actor, authorization chain, impersonation, operation, version, and the call it belonged to | for the life of the scope, and copied to the lake | | **The denial log** | each scope | the kernel, after the rollback | every *enforced refusal*: actor, permission, node, operation, impersonation | for the life of the scope | | **The admin log** | the control-plane directory | every audited `HostAdmin` verb | every *privileged change*: who provisioned, suspended, granted, admitted, rewound, reaped, with before and after | **never swept**, and it outlives a reaped tenant | | **The access log** | the directory, then R2 | every staff or delegated read | every *look*: who read what in which tenant, with how many rows | drained to R2, pruned per retention | The delivery journal (chapter 5) is a fifth record, but it is an operational one. It says what happened to an event, not who did something. Read the table by columns rather than rows, and each record covers a gap the others leave: - The outbox cannot see a refusal, because a refused operation rolls back its own events. That is why denials are written separately, after the rollback. - Neither scope-local record can see a change made *to* the scope from outside, such as a suspension, a role grant or a rewind. The admin log exists for that. - None of the three records a *read*. When a staff member or the dashboard opens a tenant's records, the access log is the only witness. It names the method, the tenant, the scope and the parameters, so "who looked at this customer's data" has an answer. Nothing in the product shows it yet. It is answered from the directory or from the R2 copies. ## The admin log, precisely Each row holds `actor`, `action`, `tenant_id`, `scope_id`, `vertical`, `before`, `after`, `caused_by` and `at`. When an executor wrote the row, `caused_by` is the event that set it off (chapter 5), so an automated change links back to the domain event behind it. Two properties are worth knowing exactly. **It is never swept.** Grant provenance and bookkeeping-law retention both depend on it, so it has no TTL and no retention phase. Its growth is handled by bounding *reads*: the HTTP surface pages, and a cursor walks the whole thing. When a tenant is reaped, its scope data and personal directory rows are destroyed and its admin-log rows are **kept**. The record of what was done to a tenant has to outlive the tenant. **The row follows the mutation, in a separate write.** An audited verb commits its change and then writes its row. A failed log write fails the verb, but the change already stands, and a crash between the two leaves a change with no row. That window is accepted and documented rather than hidden. Two verbs deliberately reverse the order, because for them the missing row is the harmful half-state: a point-in-time rewind records its intent *before* it destroys anything, and an impersonation session is logged before the session is handed back. In the dashboard this is the **Audit** page. It is a team page rather than an app tab, because some entries, such as role changes and entitlements, name no app at all. ## The access log: looking is an act Every read the control plane makes into a scope on someone else's behalf writes a row. That covers the history, facet, cause, effect and same-call reads from chapter 10 and the denial summary. This includes the dashboard's own delegated reads. A tenant's admin opening a record in their own app is recorded, not just staff doing it. The access log does not stay in the directory. The platform sweep's last phase ships unshipped rows to R2 as newline-delimited JSON, stamps them as drained, and only then prunes them from the directory. The order is the whole design. The sink must be durable before it returns, because everything downstream treats a returned ship as proof the evidence exists outside the directory. A sink that buffered and returned early would turn a retention policy into data loss. The R2 copies are pruned only if a retention period has been configured. ## Tier 2: the event stream leaves the scope Chapter 2 made a trade: every scope is its own database, so no query crosses scopes. For operational reads that is the right trade. For history it is a real cost. "Every invoice basis exported across all tenants last quarter" should not be ten thousand reads and a fold. So the platform keeps three tiers of data: 1. **Operational**: each scope's own SQLite. Live, exact, one scope at a time. 2. **Exact history**: every event from every scope, in one table, queryable with SQL. 3. **Telemetry**: sampled request data (chapter 10). Cheap, approximate, never evidence. Tier 2 is **the lake**. ### How an event gets there ``` _substrat_outbox (each scope) │ platform sweep, event-drain phase: read undrained → ship → stamp drained_at ▼ control-plane worker ── EventSink ──▶ Cloudflare Pipelines stream │ INSERT INTO sink SELECT * FROM stream ▼ Iceberg table kernel.events, in R2 (Data Catalog) ``` Tracing each hop: **The drain is a sweep phase.** It runs after the reaps, before the access-log drain, over every active scope, up to 200 events per scope per pass. A scope with more reports itself `incomplete` rather than looping, and the next pass continues. The control plane reads undrained rows over the vertical's `/internal` surface, ships them, and stamps them drained. **Read, ship, stamp is at-least-once, on purpose.** Stamping before shipping would lose events on any crash in between. Stamping after means a crash re-ships, so the lake can hold the same event twice. Every row carries its event id, which makes the duplicate recognisable: **dedupe on `id` before counting anything.** **Draining copies, it does not move.** The outbox is never pruned after a drain. The scope still holds its full history for `readHistory` and every chapter 10 view. The lake is a second copy, built for cross-scope questions. **The schema is generated.** The lake's columns are derived from the outbox DDL by a tool, and `lint:lake-schema` fails CI when the two disagree. So an envelope field added to the kernel cannot silently fail to reach the lake. The pipeline's SQL is `SELECT *` for the same reason: a record whose job is completeness should pass a new field through or fail loudly, not drop it without a word. ``` id, type, schema_version, occurred_at, tenant_id, scope_id, actor, entity_type, entity_id, pii_class, subject_id?, payload?, authorization?, impersonation?, operation?, version?, caused_by?, invocation_id?, bytes ``` `drained_at` is not shipped, since it is a fact about the scope, not the event. `bytes` is added by the shipper and is the row's serialized size. Chapter 12 explains why that column exists. **Files roll on size or time**: parquet with zstd, at most 100 MB or 300 seconds, whichever comes first. At real outbox volumes the timer always wins, so history reaches the lake about five minutes after the drain ships it. The sweep itself runs every fifteen minutes. Freshness is Tier 1's job. ### Rebuilding it A lake table can need recreating, for example after a schema change. Because the outbox is never pruned, nothing is lost. **Redrain** clears the drained stamp on events stamped before a given instant, so the next sweeps ship them again. It is a staff-only, audited admin verb, and the script that drives it works one scope at a time in bounded batches. Provisioning the lake is deliberately **not** automated in CI. The script that declares the bucket, table, stream, sink and pipeline runs as the operator's own `wrangler login`, checks the live configuration for drift, and refuses to recreate a table that holds snapshots unless the operator names that exact table as the one whose history they are discarding. ## Querying it: R2 SQL The table is Iceberg in R2, so R2 SQL reads it directly. Here is the query the platform uses to measure per-tenant event volume, with duplicates removed first: ```sql SELECT tenant_id, SUM(bytes) FROM ( SELECT DISTINCT tenant_id, id, bytes FROM kernel.events ) GROUP BY tenant_id ``` The same shape answers audit questions that no single scope can: every event a given actor produced across every tenant, every change made under impersonation in a month, or every call from a version later found to be faulty. Here is what **is not built**, stated where you would otherwise assume it was: - **No query gateway.** Nothing in the product runs R2 SQL on anyone's behalf. A tenant cannot query its own lake rows, and no dashboard or console view is backed by the lake. Today the lake is queried by whoever holds read credentials for the bucket, which means operators. - **No tenant scoping on reads.** Every tenant's events share one table, and the sink cannot partition, so a tenant-scoped read would have to be enforced by the missing gateway. - **No erasure in the lake.** Payloads ship as they are in the outbox, with `subject_id` alongside. That column exists so "delete every row for this subject" can be expressed as a query, but nothing runs it yet. Erasure is handled in Tier 1. **Until lake erasure is built, an erased subject's payloads survive in Tier 2**, and a deployment that drains to a lake has to account for that. - **No lake retention policy** is configured. Snapshot expiry and compaction are the catalog's defaults. ## Exports A tenant's data can be exported in full: every scope's dump, plus directory rows and, in unmasked mode, the tenant's admin-log entries. That is the portability export. It is a staff route today, not a self-service one. ## Denials stay home The denial log has a `drained_at` column and no drain. Denials stay in their scope. They are readable per scope, summarized by operation in the dashboard's Flow view (chapter 10), and listed in the console's per-scope panel, but they do not reach the lake. A fleet-wide "what was refused" question is still a fold over scopes. --- **Next:** [Metering and billing →](https://substrat.net/book/12-metering-and-billing.md) --- # 12. Metering and billing Source: https://substrat.net/book/12-metering-and-billing.md Two different subjects share these words, and mixing them up leads to wrong designs in both directions: - **A vertical billing *its* customers.** A support desk charging for AI answers, or a field-service firm invoicing hours and parts. That is domain logic, so it belongs in engines and verticals. - **The platform billing a *tenant* for using Substrat.** Apps installed, engines licensed, models called, storage held. That is platform logic, so it belongs in the control plane. They share one principle, and it is the thread through this chapter: **count exactly, price separately, and never let a price change a count.** ## Part one: a vertical meters its own customers ### Quantities, not prices The metering engine records *how much*. It has no currency, no rate, no plan, and no idea what anything costs. That line is drawn deliberately. One customer rounds differently, another offers a volume discount, a third changes price mid-month. None of those decisions can be wrong for *every* vertical, so by chapter 7's test none of them belongs in an engine. What would be wrong for every vertical is losing a unit or counting one twice, so that is what the engine owns. A **meter** has a key, a unit, and a kind: - a **counter** sums what is recorded (tokens used, messages sent); - a **gauge** records a level and takes the maximum within a window, carrying the last level forward into windows where nothing was recorded (seats, stored documents). A meter's kind and unit are **frozen** once set. A counter that later became a gauge would reinterpret every entry already written. ### Recording, and the invariants that make it safe ```ts // inside the vertical's own operation, in the same transaction as the work recordUsage(ctx, { meter: 'ai.tokens.output', qty: String(outputTokens), subject: { entityType: 'conversation', entityId: conversationId }, dedupeKey: `${turnId}:out`, }); ``` The engine is composed **by call** (chapter 7), and that choice matters here more than anywhere. The usage entry commits in the same transaction as the work it measures. There is no gap in which an answer was delivered and its tokens were not recorded, or the tokens were recorded for an answer that rolled back. What the engine refuses: - **A replay with the same dedupe key** returns the original entry, marked as deduplicated, and emits nothing. **The same key with a different quantity** throws. That is not a replay but two different claims about one event, and one of them is wrong. - **An entry dated inside a closed period** throws. Once a period is closed, its numbers cannot change underneath an invoice built from them. - **An entry dated more than five minutes in the future** throws. The close horizon only moves forward, so an entry dated past a close that has not happened yet could end up counted by no period at all. - **A negative gauge**, or an entry against an inactive meter, throws. ### Closing a period `closePeriod` totals every meter over a window and freezes the result as **period lines**. Closes are monotonic: a new period may not overlap one already closed. It emits `metering.period-closed` with the lines in the payload, fat as chapter 5 requires, so a consumer never has to read back. **Nothing schedules a close.** When a period ends is a business decision (calendar month, contract anniversary, on demand), so it belongs to the vertical. ### Where the price comes in The vertical holds the rate card, in its own table, with its own permission. The support-desk reference vertical stores rates per meter with an effective date, and prices usage for display by joining each entry to the rate in force when it was recorded. A rate change next month does not reprice last month. ### Handing it to invoicing Invoicing is the other half, and it is composed **by event**. A vertical or engine emits a billable event, and the invoicing engine consumes it into the customer's single open **billing basis**: the set of lines a real invoice will be issued from. Today it consumes three events: a completed work order, a placed commerce order paid by invoice, and a closed timesheet period. Each line copies its price from the event payload at write time, so last month's work is never billed at today's price. A basis holds one currency, checked on every write. Once exported it is **immutable**, which is why invoicing has no in-scope exports (chapter 5): nothing may call in halfway through and change a basis the accounts already hold. Two gaps are worth stating plainly: - **Metering does not feed invoicing yet.** Invoicing does not consume `metering.period-closed`. The intended shape is for the vertical to price the closed lines with its own rate card and emit them as a billable event. On the invoicing side that is an additive new consumed event, designed and not built. - **Nothing consumes the export event.** No accounting connector takes an exported basis today. The one accounting connector that exists reads bookkeeping *from* the ledger and has no write path. ## Part two: the platform meters a tenant ### Meter, don't bill The platform's rule is **count everything exactly, store no bill**. Usage is recorded as facts. Prices and margins are applied when someone reads, and nothing stores a computed charge. So a pricing change never requires a data migration, and a count is never tangled with a number that was a commercial decision. The commercial design names four meters. Here is the state of each: | Meter | Counts | State | |---|---|---| | **1. Base fee** | active tenants and active scopes | counted, live | | **2. Engine licensing** | entitlements, grouped by key and plan, with expired ones counted apart | counted, live | | **3. Usage** | platform-provided model calls, with tokens and list price | **counted**, and priced when read | | | event history retained | measurable in the lake, and nothing reads it yet | | | storage, API calls | **not counted** | | **4. Network transactions** | orders flowing between tenants | not countable, because that flow does not exist | Meters 1 and 2 are folds over the directory, computed on read and never stored. Operators see them, and model usage, in the console's **Meters** view, with a per-tenant card on each tenant. ### Entitlements: the licence, not the enforcement An entitlement is a row naming a tenant and a key, optionally with a plan, a quota and an expiry. The kernel enforces **presence and expiry**: a module whose key the tenant does not hold is not registered, so its operations do not resolve (chapter 3). `plan` and `quota` are **expression only**. A module can read them through `ctx.entitlement(key)` and act on them, but **nothing in the platform enforces a quota**. ### Model usage, end to end Platform-provided models are the one part of usage that is complete from call to price: 1. A vertical calls a model through the **model host**. That happens *around* an operation, never inside one, because a model call is a multi-second network round trip (chapter 4). 2. The host resolves the provider with platform-held credentials, makes the call, and builds a **usage line**: token counts, the list price computed on the platform's side from a generated rate card, and five attribution keys (tenant, scope, vertical, version, operation). 3. The vertical records that line through its own operation, which raises it as a platform intent, atomically with whatever the answer changed. 4. The platform drain checks that the line's tenant, scope and vertical match the scope it was drained from, so a vertical cannot bill a model call to someone else, and writes it to the directory. The write is idempotent on the request id and kept for 400 days. 5. When staff read the summary, a margin is applied, 20% unless configured otherwise. Nothing stores the result. Two numbers exist, and both are correct. The platform's list price plus margin is what the platform charges the tenant. The vertical's own rate card is what the tenant charges *its* customers. Reconciling them is the tenant's business. One known hole: a vertical that holds the models binding can call it directly, outside the model host, and that call is not metered. ### Storage: the honest answer **Nothing measures storage used by a scope or a tenant today.** Here is what exists nearby, and why none of it is the answer: - The Durable Object SQL API reports a database's size. Nothing collects it. Reading it means waking each scope, and no sweep phase does that yet. - Attachment rows record their own byte size. Nothing sums them, and attachment bytes live in a blob store rather than in the scope. - **The lake's `bytes` column** (chapter 11) records every event's serialized size as shipped. Summed per tenant after dedupe, it is exact, and it is the only per-tenant volume measure the platform has. But it measures **event history volume**, not database size. It excludes a scope's current rows, its indexes, its attachments, and every row an update overwrote. It is a defensible basis for billing history retention, and it is not a storage meter. So a storage line on an invoice needs one of two things built: a sweep phase that collects each scope's database size into the directory next to the other meters, or a decision that event-history bytes are the unit being sold. ### Requests are not billable either The router's per-request datapoints (chapter 10) are sampled. They are good enough to draw a chart and not good enough to put on an invoice. And a read emits no event, so the spine cannot count API calls either. That is why "API calls" sits uncounted next to storage. ### What a tenant sees The dashboard has a **Billing** page, and today it is a placeholder, marked as not yet enabled. No payment provider is integrated, no invoice is generated, and nothing takes payment. The platform counts, and the operator bills from those counts outside the product. ## The shape, once more In both halves the count is exact, deduplicated, and written in the same transaction or intent as the thing it counts. The price is applied later, by whoever owns the commercial decision: the vertical for its customers, the platform for its tenants. What is missing is also the same in both halves: the hand-off from a closed count to a document someone pays. --- **Next:** [Operating it →](https://substrat.net/book/13-operating-it.md) --- # 13. Operating it Source: https://substrat.net/book/13-operating-it.md The last twelve chapters were how the system works and what it records. This one is how it behaves when something is wrong, which is a different subject. ## Four surfaces, and three more The pieces that turn "a vertical" into "a vertical serving a customer at a hostname" are four separate deployments. | Surface | Audience | Answers | |---|---|---| | [Control plane](https://substrat.net/platform/control-plane.md) | the platform | the shared directory every vertical registers against — tenants, scopes, roles, entitlements, the admin log | | [Console](https://substrat.net/platform/console.md) | Substrat operators | *run the platform* — the whole fleet, every tenant, provisioning, the audit log | | [Router](https://substrat.net/platform/router.md) | inbound traffic | `hostname → (tenant, scope, surface)`, then dispatch | | [Dashboard](https://substrat.net/platform/dashboard.md) | a customer's admin | *run my org* — their tenant only | The split that matters most is **Console versus Dashboard**: the same platform, opposite audience and opposite blast radius. The Console is the operator's back office — all tenants, staff SSO. The Dashboard is the customer's home — one tenant, customer sign-up. Neither is called a "portal", which is reserved for a *vertical's* own end-user surface. The Dashboard is itself a Substrat vertical, which is the useful kind of dogfooding: the tenancy model either works for the platform's own product or it does not. Its shape is worth knowing before you go looking for something in it. Each app has **four tabs**: *Overview*, *Deployments*, *Data* and *Settings*. Anything about more than one app is a team page in the left menu, narrowed with an app filter: *Observability* (chapter 10) and *Audit* (chapter 11) sit under **Operate**, next to Domains, Integrations, Team and Billing under **Configure**. The rule is deliberate: a fifth tab on the app is almost always a filter on a team page instead. Three more deployments sit beside those four: - **The builder studio**: where a vertical is written with an agent, against its own workspace and snapshots. - **The egress worker**: the hop every outbound call from a dispatched vertical takes, which enforces the version's declared allowlist (chapter 8). - **The hosted issuer**: an OIDC provider that verticals can sign users in against. It runs as a vertical, with its own admin console and sign-in log, rather than as a special platform component, because authentication is a seam and not something the kernel owns (chapter 6). ## The lifecycle states, operationally Chapter 2 listed them. Here is what each one *means* when you are the person deciding. | State | Serves traffic | Data | Reversible | |---|---|---|---| | `provisioning` | no | being created | — | | `active` | yes | live | — | | `suspended` | **no**, fails closed | intact | yes | | `archiving` → `archived` | no | intact | yes | | `reaped` | no | **gone** | **no** | Tenants have the parallel ladder, where `deleting` is the reversible grace state that makes every scope beneath it inert without reclaiming anything. Two things to internalise: **Suspension is the live weapon.** It takes effect on the next request, because the router does not cache routes and `getScope` re-checks the directory. When a customer must stop being served *now*, this is the lever, and its immediacy is paid for with a directory read per request. **`archived` is not `deleted`.** The bytes are still there and cost money. Moving to `reaped` is what frees them, it is irreversible, and — per chapter 9 — nothing does it automatically unless a retention window has been configured. If your mental model is "archiving cleans up", it does not. ## Four failures, four instruments Backup is where people's mental models are usually wrong, because "backup" is four different things here. | What is lost | What covers it | |---|---| | A scope's data, wrongly changed | **Durable Object PITR** — ~30 days, continuous, per scope. A destructive rewind of the live app. | | A scope, reaped | **The copy the reap takes first.** A reap is irreversible, so the control plane stores a full-fidelity dump *before any byte goes* and records its address on the admin-log entry. | | A scope, wanted elsewhere | **`scope pull` / snapshots** — a non-destructive copy that leaves the live app alone. | | **The directory itself** | **The scheduled directory backup.** | That last row is the one the others cannot cover, and it is worth understanding why. The directory is a single Durable Object. Lose it and **no scope can be found**, even though every scope's data is sitting there completely intact. PITR does not help: PITR rewinds a database that still exists, and having nothing to point it at is a different failure. And no scope can rebuild the map from below, because a scope does not know its own tenancy, hostname, or bound version. So the control plane backs itself up: a scheduled dump of the whole directory — tenants, scopes, hostnames, verticals, entitlements, identities, and the audit log — to an object store outside the DO, once a day, keeping 30 copies. Two details in that design are worth borrowing for anything similar. The cadence is derived by reading the newest stored copy rather than from a separate timer, so a missed run is caught up on the next pass rather than skipped. And retention is pruned only **after** a new copy lands, so a failed backup can never be the thing that deletes the last good one. RPO ≤ 24h, RTO ≤ 1h for a directory loss, and the restore is rehearsed rather than merely designed — the round trip runs in the contract suite against both adapters: capture, diverge, restore, keep serving. **Restoring replaces.** The dump's contents *become* the directory; anything created since is gone. A merge would silently interleave two histories of the same tenant, so replace is the honest semantic, and the restore refuses a directory that still holds tenants unless the request explicitly says to overwrite — a guard for the realistic hazard of a restore replayed against a control plane that has already recovered. What it does **not** cover, stated plainly: the backup bucket lives in the platform's own account. This survives losing the directory; it does not survive losing the account. And a restore does not bring back what was never in the directory — the staff roster, worker secrets, or the key any sealed credential was sealed with. ## Where to look when something is wrong Chapters 10 and 11 describe the instruments. What matters when something is wrong is which one witnessed the thing you are asking about, because reaching for the wrong one is the usual reason an investigation stalls: | Question | Instrument | In the dashboard | |---|---|---| | Is it slow, or failing, for everyone? | router traffic (sampled) | Observability → Traffic, Health | | What did this request do, and what did it print? | invocation log | Observability → Logs | | What happened to this record, and who did it? | the outbox, via `readHistory` | the app's Data tab → a record | | Why does this event exist? | `causedBy` | a record's event → *Why?* | | Did the consumer ever handle it? | the delivery journal | a record's event → *What did it do?* | | Everything this request changed | `invocation_id` | a record's event → *Same call* | | Has this job stopped running? | sweep runs, freshness | Observability → Schedules | | Why was this person refused? | the denial log | Observability → Flow (per-operation health) | | Who changed this app's configuration? | the admin log | Audit | | Who looked at this tenant's data? | the access log | no view yet: it drains to R2 (chapter 11) | | Did the platform act on what the app asked for? | the platform-request journal | Settings → Integrations | Add to those the sweep reports from chapter 9 (`retrying`, `deadLettered`, and per-unit `errors`), which are the signal that background work is degrading rather than failing loudly. ## A short diagnostic index Symptoms whose cause is rarely where you first look: **"The deploy went green and production is unchanged."** A push is not a deploy. Check whether the version was promoted, and whether the scope is bound to it. **"One tenant is broken, everyone else is fine."** Very likely a failed migration on that scope. It fails closed and serves nothing by design. The migration-reconciliation phase records why; the scope's `migrationFailure` carries the version and the error. **"The event fired but nothing happened."** Open the event's *What did it do?* tree, which is the delivery journal. If the consumer is in-scope, remember from chapter 5 that it **does not retry** — one failure is terminal for that pair, and waiting will not fix it. If it is an executor, check `next_attempt_at`: it may be backing off, or already dead-lettered at `maxAttempts`. **"The app asked for something and it took a quarter of an hour."** The platform intent waited for the sweep. The vertical is not flagging its responses, so the router never kicked a drain. Wire `onPlatformRequests` when minting the stub (chapter 5). If the intent never settles at all, find it in the platform-request journal (Settings → Integrations for a connector's intents). A handler that keeps throwing leaves the intent pending for about a day and then fails it, with the last error kept. **"Observability shows no traffic for an app that is clearly in use."** Either the version predates the invocation log, or the log is mounted below some routes, or it has no `routerSecret` and verifies nothing. All three produce silence rather than an error (chapter 10). **"Recurring work stopped."** Check the scope sweeper's roster. The alarm **lapses on an empty roster**, and the roster is maintained by `/internal/provision` and `/internal/delete-scope` rather than read from a directory. A scope that never called `noteScope` is not swept. **"Permission denied and I do not know why."** The denial log gives the key and the node. `explain` gives the chain. If the permission is tenant-level, remember it reaches the scope by **projection** — and an absent projection is a deny, which is the safe direction but also a real cause. **"It works locally and fails deployed."** The adapters are contract-equivalent, so suspect the things that are not module code: the router secret, a binding, an unset platform secret, a grant that was never made. Both trust boundaries fail closed, which means the symptom of a missing secret is a blanket refusal rather than a subtle misbehaviour. ## What is honestly not solved Ending on the gaps, because a book that implies completeness is worse than one that names the edges. - **The directory is a single control-plane DO.** The tenant-root-DO plus global-D1 split is designed and not built. It is the obvious scaling and blast-radius question. - **Per-jurisdiction DO ids are not built.** `eu` and `us` name guarantees whose enforcement is not there yet, which is why provisioning gates them. `global` is what every scope is today. - **In-scope consumers do not retry.** Deliberate for v0, and worth re-deciding as verticals grow consumers that fail for transient reasons. - **Dual-emit across a schema version is not available.** Postponed with a design, not abandoned — but no plan should promise a deprecation window until it exists. - **Cross-scope reads are a fold, not a join.** Fleet-wide questions cost a read per scope. Projections and per-tenant D1 are the current answers, and the many-scope fan-out cost of permission projection for a very large tenant is an explicit open question. - **The lake has no query gateway, no tenant scoping and no erasure.** Tier 2 is filled and queried by operators. A subject erased in Tier 1 still has payloads in Tier 2 until lake erasure is built (chapter 11). - **Storage is not metered, and quotas are not enforced.** The platform counts installations, engine licences and model calls. It cannot yet say how many bytes a tenant holds, and an entitlement's quota is information a module may act on, not a limit the platform applies (chapter 12). - **Denials and deliveries carry no invocation id**, so "same call" reaches a request's events but not its refusals. - **Executors hold the scope's turn on the local SQLite adapter** and not on Durable Objects. The two hosts differ only under a slow connector, but they do differ. - **Grant expiry transitions and facet recency are contract-tested on SQLite only**, because the DO host takes no clock. Both hosts run the same SQL; only one can be tested across the passage of time. [What Substrat doesn't have (yet)](https://substrat.net/guide/what-substrat-lacks.md) is the maintained version of this list. ## Where to go next You now have the shape. The reference is the right tool from here: - Building something — [Getting started](https://substrat.net/guide/getting-started.md), then the [todo walkthrough](https://substrat.net/guide/walkthrough-todo.md), then [The model](https://substrat.net/concepts/model.md). - Choosing an engine — [What is an engine?](https://substrat.net/engines/index.md) and the five pages each one carries. - Shipping — [Deploying a vertical](https://substrat.net/guide/deploying.md) and [Environments & previews](https://substrat.net/guide/environments-and-previews.md). - Billing your own customers — the [metering](https://substrat.net/engines/metering/index.md) and [invoicing](https://substrat.net/engines/invoicing/index.md) engines. - Operating — the four [platform surfaces](https://substrat.net/platform/index.md). And if you are an agent rather than a person: [Agent rules](https://substrat.net/guide/agent-rules.md) is the page to read before writing any code, and [llms.txt](https://substrat.net/llms.txt) is the index. --- *That is the end of the book. [Back to the start →](https://substrat.net/book/index.md)* --- # What is Substrat? Source: https://substrat.net/guide/what-is-substrat.md Substrat is a substrate for building **vertical B2B SaaS** — the software a property manager, an installation firm, or a point-of-sale chain actually runs their business on. AI has made building that kind of software fast and cheap — except for the parts that were never about writing code: multi-tenancy, identity, permissions, integrations, data integrity, audit, and GDPR. Those are exactly the parts that are catastrophic when wrong, and the parts LLM-generated code gets wrong most often. Substrat owns those hard parts and enforces them **at runtime**, so small teams — including non-engineers wielding AI tools — can build production-grade products on top, at AI speed, without the speed being fatal. > We build the substrate. You build the verticals. ## The three layers Everything in Substrat hangs off one decomposition: ### 1. Kernel Everything that is true of *every* B2B SaaS, and nothing that is true of any particular one: identity, nested tenancy, permissions, events/audit, the module system, GDPR machinery. The kernel owns **no domain entities** — there is no customer table and no work-order table in the kernel. Instead it offers *attachment contracts* (events, permissions, documents, relations) that bind to opaque `(entityType, entityId)` references your code defines. Your domain stays yours; the guarantees stay the kernel's. ### 2. Engines Domain machinery shared across verticals but too domain-shaped for the kernel: work orders, invoicing, scheduling, ticketing. Engines are headless, versioned npm packages that register into the kernel like any other module. Engines own **invariants**: state machines can't skip states, time entries are append-only, exported invoices are immutable, every mutation emits an event, every access passes the permission check. Verticals own everything with a user's fingerprints on it: vocabulary, extra fields, triggers, pricing logic, UI. The design test for the boundary: *if a vertical ever needs to fork an engine, the engine drew its line wrong.* See [What is an engine?](https://substrat.net/engines/index.md) for the composition rules and the engines that exist today. ### 3. Verticals The actual products — your code. A vertical composes the kernel and one or more engines, adds its own module (schema, operations, screens), and ships. Verticals are where AI tools do their best work, because the layer where LLMs are strongest (screens, forms, workflows, reports) is the layer where mistakes are cosmetic — the catastrophic layer sits below the API surface, enforced by the kernel. ## What "enforced at runtime" means concretely Code built on Substrat **cannot**: - **reach another tenant's data** — data access only exists as capability-scoped operations invoked through a stub minted for one `(tenant, scope)` pair; a mismatched pair fails closed rather than resolving to someone else's data; - **skip the audit log** — events are stamped with tenant, scope, actor, and timestamp *below* the API surface; calling code cannot forge, suppress, or mislabel them; - **emit unclassified PII** — every event carries a mandatory `piiClass`, and a PII-classed event without a data-subject key fails validation, so GDPR erasure is always possible; - **bypass the permission model** — operations run inside the scope's execution domain and check permissions there, and every allow carries the proof path that granted it. None of this depends on the discipline of the code above it — which is the point, because increasingly that code is written by an agent. ## Current status Substrat is pre-release (0.x). What exists today: | Piece | Package | Status | |---|---|---| | Contract schemas (Zod, source of truth) | [`@substrat-run/contracts`](https://substrat.net/reference/contracts.md) | Working | | Kernel interfaces (scope host, permission checker) | [`@substrat-run/kernel`](https://substrat.net/reference/kernel.md) | Working | | Pure-SQLite scope host (local dev, CI, self-host) | [`@substrat-run/adapter-sqlite`](https://substrat.net/reference/adapter-sqlite.md) | Working | | Adapter conformance suite | [`@substrat-run/contract-tests`](https://substrat.net/reference/contract-tests.md) | Working | | Cloudflare scope host (Durable Objects, production) | [`@substrat-run/adapter-cloudflare`](https://substrat.net/reference/adapter-cloudflare.md) | Working — same suite, real workerd; scope-local permissions + the router landed | | Work-order engine | [`@substrat-run/engine-workorder`](https://substrat.net/engines/workorder/index.md) | Seed | | Booking engine | [`@substrat-run/engine-booking`](https://substrat.net/engines/booking/index.md) | Seed | | Invoicing engine | [`@substrat-run/engine-invoicing`](https://substrat.net/engines/invoicing/index.md) | Seed | | Protocol / checklist engine | [`@substrat-run/engine-protocol`](https://substrat.net/engines/protocol/index.md) | Seed | | Invites engine | [`@substrat-run/engine-invites`](https://substrat.net/engines/invites/index.md) | Seed | | Absence engine | [`@substrat-run/engine-absence`](https://substrat.net/engines/absence/index.md) | Seed | | Metering engine | [`@substrat-run/engine-metering`](https://substrat.net/engines/metering/index.md) | Seed | | Scrive connector (e-signing, BankID) | [`@substrat-run/connector-scrive`](https://substrat.net/connectors/scrive.md) | Published | | Fortnox connector (accounting, SIE4 on a poll) | [`@substrat-run/connector-fortnox`](https://substrat.net/connectors/fortnox.md) | Published | | Planima connector (planned facility maintenance, read-only) | [`@substrat-run/connector-planima`](https://substrat.net/connectors/planima.md) | Published | | The `substrat` CLI — authenticated deploy (`login`, `push`) | [`@substrat-run/cli`](https://substrat.net/guide/deploying.md) | Working | | Platform surfaces — [control plane](https://substrat.net/platform/control-plane.md), [console](https://substrat.net/platform/console.md), [router](https://substrat.net/platform/router.md), [dashboard](https://substrat.net/platform/dashboard.md) | private deployments | Working (self-serve deploy foundation) | | Callout (field service) — the canonical composition; first CP-less pushable vertical | [`demos/callout`](https://substrat.net/verticals/callout.md) | Working | | Handlebar (bike workshop) — engine reuse under new vocabulary | [`demos/handlebar`](https://substrat.net/verticals/handlebar.md) | Working | | Kallkälla (coffee shop) — two audiences, one API; commerce | [`demos/shop`](https://substrat.net/verticals/shop.md) | Working | | Meridian (HR) — leave/absence, time, expenses, onboarding; one role-adaptive app | [`demos/meridian`](https://substrat.net/verticals/meridian.md) | Working | | Manyfold (headless CMS) — multi-scope, *site = scope*; the editorial lifecycle as the invariant | [`demos/manyfold`](https://substrat.net/verticals/manyfold.md) | Working | | Todo (shared lists) — user-initiated sharing on a record app; no engine, by design | [`demos/todo`](https://substrat.net/verticals/todo.md) | Working | | ticket0 (support desk) — a public, unauthenticated surface; the assistant as a member of staff | [`demos/ticket0`](https://substrat.net/verticals/ticket0.md) | Working | | Tock (measured file loads) — a runtime schema the data may disagree with; corrections that supersede without destroying | [`demos/tock`](https://substrat.net/verticals/tock.md) | Runs locally — not deployed | Interfaces change without notice until the first vertical ships. **Meridian** is the deliberate shape-breaker: a domain with *no ready-made engine*, so leave/absence, time reporting, and expenses are all vertical code — proof the kernel's guarantees (nested tenancy, permissions, audit, GDPR) hold with zero engine support, and that its value isn't borrowed from the work-order state machine. It reuses only the protocol engine (onboarding) and is the vertical that surfaces the [absence / entry-ledger engine](https://substrat.net/engines/index.md#engines-today) candidate. It also exercises what Callout doesn't: two country scopes (Sweden 25 days + saved days, Spain 22 + *registro de jornada*) diverging from one codebase, and a single app that adapts to the person — an employee sees only their own record; a team lead who is *also* an employee gets a **Manage** section beside their own **My work**, all behind the same permission checks. --- # Why runtime enforcement? Source: https://substrat.net/guide/why-substrat.md Every adjacent way of getting a "production-grade foundation" delivers its guarantees as **conventions**: - **Templates and boilerplates** give you correct code once — and every edit after that, human or AI, erodes it. Nothing stops the fifth iteration of a form handler from querying across tenants. - **Code generators** produce the foundation and leave. "Zero lock-in, no runtime dependencies" also means zero enforcement the day after generation. - **BaaS platforms** enforce at runtime, but make the guarantee contingent on rules the builder must write correctly — row-level-security policies are precisely the thing inexperienced builders and LLMs misconfigure most. The industry pattern is well documented: analyses of AI-generated apps keep finding the same failure list — missing row-level security, broken auth boundaries, no tenant isolation, no audit trail. Prompting the model to "be secure" measurably doesn't fix it. ## The structural insight **The layer where LLMs are weakest — tenancy, auth, migrations, integrations, compliance — is the layer where mistakes are catastrophic. The layer where LLMs are strongest — screens, forms, workflows, reports — is the layer where mistakes are cosmetic.** Substrat puts humans and hard guarantees under the line, and AI velocity above it. ## Defaults, not configuration The subtler failure mode than "no enforcement" is **enforcement you can misconfigure**: platforms where isolation is real but contingent on builder-declared policy — a public ACL here, a system-mode default there. Substrat guarantees are defaults of the substrate, not configuration surfaces: - There is no API that returns another scope's data with the wrong flag set — the API for reaching a scope *is* the isolation mechanism. - There is no "remember to log this" — the event envelope is stamped kernel-side on every `emit`, and the audit-relevant fields (tenant, scope, actor, time) are not parameters. - The permission checker's secure default is **deny everything**. The permissive test checker is exported as `UNSAFE_allowAllChecker` — the name is the warning. ## Why this survives better models Even a future AI that writes flawless tenancy code doesn't solve the **trust** problem: someone has to underwrite that isolation, audit, and GDPR hold structurally — to a customer, an auditor, or a procurement checklist. A property of the substrate can be tested, demonstrated, and certified once, below all the code that changes daily. That's also why the enforcement is verified mechanically: every adapter that hosts scopes must pass [the same conformance suite](https://substrat.net/reference/contract-tests.md) — isolation, serialization, fail-closed addressing, stamped envelopes — unchanged, forever. ## The two human checkpoints Substrat is built for verticals that iterate at AI speed, but two things stay under human review even in a fully agent-driven shop: 1. **Schema migrations** — the blast radius of a bad migration is data, not pixels. 2. **Permission definitions** — who can do what, where in the tree, reviewed as a human-readable diff. Everything else iterates freely with contained blast radius. The module manifest and the permission model are designed to make exactly these two reviews small and legible: permissions are declared with descriptions in the manifest, and every permission decision carries a proof path that explains it. --- # How Substrat compares Source: https://substrat.net/guide/comparisons.md Substrat sits in a category most people meet for the first time here, so the fastest way to understand it is against the tools you already know. This page places it next to each familiar neighbor and names the *one axis* where it diverges. It is deliberately not a scoreboard. Every tool below is good at what it was built for, and several are excellent; the point is to show what Substrat is by showing what it trades away and what it keeps. If your problem is the shape one of these neighbors was built for, use that neighbor — the last section says so plainly. ## The three-way choice everyone else forces Today a team building vertical B2B software has to pick two of three things — governance, code ownership, and an open, agent-friendly runtime: | Approach | Examples | What you get | What you give up | |---|---|---|---| | **Governance without code** | OutSystems, Mendix, Power Apps, ServiceNow App Engine | Real permissions, audit, compliance — as a hosted platform | A proprietary visual model; no code you own; agent-hostile | | **Code without governance** | Supabase, boilerplates, prompt-to-app tools | Full code ownership, fast start, agent-friendly | Every catastrophic mistake — tenant leaks, missing audit — is yours | | **Both, walled garden** | Salesforce, ServiceNow | Governance *and* depth, at the top of the market | Proprietary runtime, enterprise pricing, build-inside-our-world | Substrat is the missing fourth corner: **governance as a runtime substrate, under code you own, on an open runtime, shaped for multi-tenant vertical SaaS.** The guarantees live [below the API surface](https://substrat.net/guide/why-substrat.md), so it stops mattering who — or what — wrote the code above them, and you still own that code. ## The table, for scanning Read it as *what each product optimizes for*, not as a scoreboard. Several of these are better than Substrat at what they set out to do, and the third column says so. | Product | What it really is | Where it beats Substrat | Where Substrat differs | |---|---|---|---| | **Ruby on Rails** | The reference full-stack framework | Maturity, ecosystem, hiring pool, twenty years of answers. Rails + a good team beats Substrat on almost any *single*-tenant app | Rails gives you conventions, and conventions erode with every edit. No tenancy, permissions, audit or GDPR machinery below the app — that is yours to build and keep correct | | **Wasp** | Rails-for-TypeScript: a declarative spec compiled to a React/Node/Prisma app | Excellent DX, MIT, no hosting lock-in, strong AI-coding compatibility, and a SaaS template that gets you selling | Convergent instinct — a typed spec the compiler checks; our [`model.ts`](https://substrat.net/concepts/model.md) rhymes with theirs — and opposite depth. Wasp is a framework you deploy; Substrat is a runtime that *enforces*. We keep their perks (deterministic generation, pre-vetted auth, local run, migrations) rather than trading them away for the guarantees | | **Supabase** | Backend-as-a-service with row-level security | Better DX, bigger ecosystem, far better for app-shaped products | App-shaped, not vertical-SaaS-shaped. RLS is precisely the surface builders and LLMs misconfigure most | | **Convex** | Reactive TypeScript backend — database, functions, scheduling and sync in one runtime — with sandboxed **Components** as installable backend modules | The closest architectural peer here, and ahead of us on axes we care about: reactivity as a primitive, a component catalogue that ships today, transactions that span component calls, SOC 2 Type II + HIPAA, and a backend you can self-host. Their DX is better than ours and it isn't close | Convex closed the RLS hole the same way we did, so the difference isn't enforcement — it's *shape*. No tenancy tree, no scope-per-customer, no audit spine below the API, no erasure machinery. Their components are technical primitives (workflow, rate limiter, aggregate); a [domain engine](https://substrat.net/engines/index.md) that owns a work order's state machine is a different animal | | **MakerKit / ShipFast / Open SaaS** | Boilerplates, one-time purchase | Unbeatable economics *if* the guarantees can be conventions | Guarantees erode with every edit; no nested tenancy, no provisioning, no engines | | **Lovable / Bolt / Replit** | Prompt-to-app | Enormous distribution, polish, iteration speed | They generate the dangerous parts too. Their remedy is *scanning* — checking policies exist, not that they hold. Here you bring your own model and your own agent, on code that runs locally | | **Base44, Floot** | AI-native app builders with auth/roles as platform primitives | The nearest AI-native articulation of the same idea, with real products and real users | Single-app shaped — no tenancy tree, no engines, no B2B SaaS shape — and weak portability. Substrat code boots on SQLite with no platform in the loop | | **Retool / Superblocks** | Internal tools with governance | Owns the internal-tool shape outright — genuinely better there | No nested tenancy for *selling* SaaS; runtime-locked | | **OutSystems / Mendix / Power Apps** | Enterprise low-code | Genuinely solve permissions, audit and governance as a hosted platform — and prove the willingness to pay | A proprietary visual model: agent-hostile, internal-app-shaped, single-level tenancy, no usable eject | | **Microsoft Dataverse** | Proprietary data platform with runtime-enforced security roles | The most mature RBAC in this table by a distance — row *and* column security, business-unit hierarchies, team ownership, audit built in | Business units are org structure *inside one tenant*, not tenancy for selling SaaS to many customers; per-user licensing lands on your customer, which breaks vertical-SaaS economics | | **Salesforce / ServiceNow** | The top of the market | Ecosystem, certifications, integrator army, indemnification | Proprietary runtime, enterprise pricing, no real exit. Apex defaults to *system* mode — enforcement contingent on the developer | | **Odoo / Frappe** | Platform-with-modules | The biggest module ecosystems on earth; a real business today | You inherit their ORM, worldview and upgrade treadmill; single-org shaped | | **Medusa v2** | E-commerce with strict module isolation | Independently converged on the module/engine model, shipping in production | E-commerce-scoped, no native multi-tenancy, no enforcement layer around the modules | | **Baseplate.dev** | Deterministic codegen you eject from | Zero lock-in — literally its proudest feature | The exact opposite pole: they generate the foundation and leave; we *are* the foundation and stay | | **DIY: WorkOS + Nango + Inngest + Stripe** | Assemble it yourself | Best-in-class at each piece; no platform bet | Every seam between them is yours to keep correct forever, and none of them enforces anything about the code above | **What none of the AI builders in this table have** is an oracle independent of the code they generate: they produce the app and its tests from the same act of generation, so the tests inherit the same misunderstanding and pass. Frameworks don't generate your tests at all, which is honest but leaves the second opinion to your discipline on a Friday afternoon. Deriving code from the model and [tests from the concept](https://substrat.net/guide/ai-guardrails.md#guard-04-two-descriptions-that-are-allowed-to-disagree) is cheap, and it catches the exact failure mode — small, confident, inconsistent mistakes — that makes generated systems untrustworthy. ## The neighbors, one by one ### Full-stack frameworks *Ruby on Rails, Wasp, Laravel.* The honest baseline, and the one most readers should measure against first: for a single-tenant app, a good framework and a good team beat Substrat, and it isn't close. What a framework gives you is **conventions** — and conventions are exactly what erodes when the fifth iteration of a handler is written at speed, by a person or an agent, and drops a `WHERE` clause nothing was enforcing. Wasp is the closest in instinct: a declarative spec the compiler checks, compiled down to a working app — the same conviction that a project should have one high-level description a human and an agent can both hold in their head. Substrat's [`model.ts`](https://substrat.net/concepts/model.md) rhymes with `main.wasp.ts` deliberately. The divergence is depth, not direction. Wasp is a framework you deploy; Substrat is a runtime that *enforces*, with tenancy, permissions, audit and GDPR below the API surface rather than in the app you generate. And the trade usually demanded for that — giving up the framework perks — is one we tried hard not to take: deterministic generation, auth you never write, local run, connectors, and platform-owned migrations are all still here. If your problem is one app for one organization, take the framework. ### Templates & boilerplates *MakerKit, ShipFast, Open SaaS, Bullet Train.* The closest articulation of "an LLM-friendly foundation you own" — and the honest one about its own economics (a one-time purchase, then it's yours). The difference is durability: a template gives you correct code *once*, and every edit after that, human or AI, can erode it. Nothing at runtime stops the fifth iteration of a handler from querying across tenants. Substrat's guarantees are enforced by the substrate on every call, not printed into a starting point. Templates also stop at the single-app shape — no nested tenancy, no provisioning, no shared domain engines. ### Prompt-to-app *Lovable, Bolt, Replit, and similar.* These generate the whole app, including the dangerous parts. That's their ceiling for B2B: the parts an LLM gets wrong most often — tenant isolation, auth boundaries, audit — are exactly the parts they generate rather than stand on. Their remedy, security *scanning*, checks that policies exist, not that they hold. Substrat inverts the split: the dangerous 30% is a hardened substrate; the safe, high-velocity 70% — screens, forms, workflows — is where the generation happens. The two are complementary, not rivals: a prompt-to-app tool pointed at Substrat's manifest generates *above* the guarantees instead of reinventing them. ### Backend-as-a-service *Supabase.* BaaS does enforce at runtime — but it makes the guarantee contingent on rules the builder writes correctly. Row-level-security policies are precisely the surface inexperienced builders and LLMs misconfigure most; the isolation is real *if* every policy is right. Substrat's isolation is not a policy you author — [the API for reaching a scope](https://substrat.net/concepts/scope-host.md) *is* the isolation mechanism, and its secure default is deny. BaaS is also app-shaped rather than vertical-SaaS-shaped: no nested tenancy tree, no module system, no domain engines. ### Reactive backends with modules *Convex.* Worth separating from the row above, because the usual BaaS critique doesn't land here. Convex reached the same conclusion we did — [that RLS is a ticking timebomb](https://stack.convex.dev/why-convex-doesnt-need-row-level-security), because authorization split into a second language in a second place is a thing you have to get right twice and update twice — and closed the hole the same way, by putting every database access behind server functions. Their Components are the nearest mechanism to our engines from the other direction: mini-backends with private tables the host app cannot read, an explicit API, runtime validation of arguments *and* return values, and calls that commit as sub-transactions inside the caller's. So the difference is shape rather than enforcement. Convex is built for one app: there is no tenancy tree, no scope per customer, no audit spine below the API, no erasure machinery, and authorization is a convention you repeat in every function rather than a check whose absence fails a lint. And their catalogue holds *technical* primitives — workflow, rate limiter, aggregate, migrations — where a [domain engine](https://substrat.net/engines/index.md) owns a business invariant like a state machine that cannot skip a state. If your product is one app that wants live data, Convex is an excellent answer and a better developer experience than ours. ### Low-code / enterprise app platforms *OutSystems, Mendix, Appian, Power Apps, ServiceNow App Engine.* The strongest existing answer on governance — they genuinely solve permissions, audit, and compliance as a hosted platform, and the market pays enterprise prices for it. The trade is that all of it lives inside a proprietary visual model: AI copilots generate *into* that model, deepening lock-in rather than producing code you own; the shape is internal-app, not multi-tenant SaaS you sell; and even the best tenancy in the class is single-level. Their existence is the demand evidence for the category. Substrat targets the same governance, but as a substrate under TypeScript you own, [agent-first by design](https://substrat.net/guide/ai-agents.md), with nested tenancy as the reason it exists. ### Salesforce & ServiceNow *The top of the market.* Proof the category works at scale — governance, depth, and now AI app generation, sold to the largest enterprises. And proof of the trade Substrat refuses: a proprietary runtime, enterprise pricing, US-hosted, build-inside-our-world with no real exit. Substrat is the code-first, developer-owned, EU-shaped version for the SME and mid-market segment that tier prices out — a different customer, deliberately. ### Odoo & Frappe *The platform-with-modules thesis, executed.* The nearest thing in spirit to "apps on a shared platform," and worth understanding closely because the resemblance is surface-level. On Odoo you adopt an entire ORM, worldview, and upgrade cadence; apps extend each other by inheritance and share one database, so the platform is a single-organization shape — reseller multi-tenancy is something integrators build themselves, per tenant. Frappe's everything-is-a-DocType metadata model is the dynamic-schema approach Substrat [deliberately avoids](https://substrat.net/concepts/modules.md): modules own their own typed tables and migrations instead. The engine model draws the opposite boundary from an Odoo app — see [what an engine is *not*](https://substrat.net/engines/index.md#what-an-engine-is-not). ### Medusa *The architectural cousin.* The one neighbor Substrat resembles by convergence rather than contrast. Medusa v2's modules are strictly isolated, associate through link tables instead of foreign keys, and carry per-module migrations — independently arriving at the same engine model, and shipping in production. It validates the architecture. The differences are scope and layer: Medusa is e-commerce-shaped, runs one instance per tenant rather than native multi-tenancy, and has no enforcement layer around the modules. Substrat generalizes the module isolation and wraps it in [nested tenancy](https://substrat.net/concepts/tenancy.md) and runtime enforcement. ### Assembling the pieces yourself *Clerk / WorkOS for identity, Nango for integrations, Inngest for jobs, Stripe for billing.* Every ingredient Substrat bundles exists as an excellent standalone product, and a capable team can wire them together. What that assembly doesn't give you is the *seam*: identity, tenancy, permissions, events, and integrations enforced as one coherent substrate, with domain engines on top and one audited path through all of it. You own the glue — and the glue between governance systems is exactly where the catastrophic mistakes live. Substrat is the opinionated bundle where the seams are the product. ## When Substrat is the wrong tool The boundary is as much a part of the definition as the category. Reach for something else when: - **You're building a single-tenant internal tool.** Without a tenancy tree or a per-tenant compliance surface, most of the kernel is dead weight — Retool, Power Apps, and the BaaS platforms own that shape. - **One tenant is data- or scale-heavy.** The scope-per-customer model suits many operationally-rich tenants, not one tenant with hundreds of millions of hot rows. - **The moat is deep domain logic, not foundation cost.** Accounting, payroll, core banking — decades of domain depth. Integrate those; don't rebuild them. - **The foundation isn't your binding constraint.** Consumer-scale apps, ML-first products, realtime-collaboration tools — different physics, where tenancy and audit aren't the bottleneck. Substrat fits where apps are **structurally repetitive but operationally rich**: workflow-shaped B2B products whose foundation — tenancy, permissions, audit, GDPR — is the expensive, identical part, and whose differentiation is vocabulary, states, and compliance content. Where the foundation isn't the cost driver, the substrate stops paying rent. --- # What Substrat doesn't have (yet) Source: https://substrat.net/guide/what-substrat-lacks.md A page that only lists strengths is a document nobody trusts twice. This one names the gaps, says which are deliberate refusals and which are simply unbuilt, and separates the two — because "we chose not to" and "we haven't got to it" are different admissions, and blurring them is how these documents start lying. If you are evaluating Substrat, read this page before the marketing one. It is the faster way to find out whether the answer is no. ## How claims are labelled Everywhere in these docs, a capability is one of three things: | label | means | |---|---| | **shipped** | it exists in the repo, it is exercised by tests or by a running deployment, and you can go read the code today | | **built, unproven** | the code exists and works, but it hasn't survived the load, the customer count, or the second use case that would make it a fact | | **bet** | a design position we believe and have not yet earned the right to assert | Substrat is 0.x. Most of the platform is in the first column, the engine-reuse thesis is in the second, and the market thesis is in the third. ## The gaps | Capability | Who has it | Where we are | |---|---|---| | **Integration catalogue** | Zapier, Power Automate (1000+ connectors), Nango (400+) | **Genuine gap, and the most practical one.** One production connector. The [connector framework](https://substrat.net/connectors/index.md) is right; the library is a rounding error | | **Certifications** (SOC 2, ISO 27001, HIPAA) | Every enterprise incumbent, several AI app builders, and Convex (SOC 2 Type II + HIPAA) | **Genuine gap, and the sharpest one relative to our own pitch.** We sell trust and cannot yet hand a procurement officer the one page that prices it | | **DSAR *access-request* export** (GDPR Art. 15) | Anyone selling GDPR compliance as a feature | **Half-built, and it's the visible half.** Art. 17 erasure ships with a receipt; the Art. 15 export that answers *"what do you hold about me"* does not, and neither half has a UI. Odd from outside — we do the cryptographically hard part and not the part a customer asks for first | | **Semantic / vector search** | Essentially everyone | **Half-built, and the half we can honestly build is done.** Full-text ships: an entity declares `searchables` and the kernel derives a per-scope FTS5 index and its triggers ([Reads & scaling](https://substrat.net/concepts/reads.md#finding-a-row-by-what-someone-typed)). The vector half is blocked rather than unscheduled — Durable Object SQLite permits `fts5` and rejects `vec0`, so an in-scope embedding index is not implementable today whatever we decide about it | | **Localization** | Every mature vertical product | **Planned, unbuilt.** Today it is an `// i18n key` comment. Retrofits here are miserable | | **Realtime subscriptions / presence** | Supabase, and Convex, where reactivity is the primitive rather than a feature | **Planned, unbuilt.** Nearly free on scope DOs — which is why not building it yet is a choice rather than a constraint. Convex is the evidence for how much product falls out of it once it exists | | **Native mobile + offline sync** | The enterprise low-code tier | **Planned, scoped hard, unbuilt.** Append-only capture flows only; general offline CRUD is a sync/conflict tarpit | | **Data import / legacy migration tooling** | Salesforce Data Loader, Odoo import | **Planned, unbuilt** — and it is the biggest sales barrier, since every sale is a migration out of an incumbent. We are greenfield-only today | | **Billing your customers out of the box** | Open SaaS (Stripe), the app builders' payment primitives | **Partial.** [Metering](https://substrat.net/engines/metering/index.md) and entitlements exist for *platform* billing; the vertical-bills-its-own-customers rail is engine territory and not shipped | | **A catalogue of installable backend modules** | Convex Components — sandboxed modules installed from npm, with a directory and an `llms.txt` for agents | **Same shape, no distribution.** Our [engines](https://substrat.net/engines/index.md) are npm modules with private tables and an explicit surface; there are seven, all written by us, and no third party has installed one. Their catalogue proves the *distribution* half of the idea below — for technical primitives, which is the easier half | | **Marketplace + third-party ecosystem** | AppExchange, Odoo Apps, OutSystems Forge | **Planned, unbuilt.** A design doc is not an ecosystem, and ecosystems take years | | **End-user report / dashboard builder** | Salesforce, Odoo, Retool, Power BI | **Deliberate refusal** — resist configurability until a customer pays for it. It will still lose a bake-off, and knowing why doesn't make the demo go better | | **No-code admin customization** | Salesforce Flow, Odoo Studio, Dataverse | **Deliberate refusal.** No visual process builder — that is a tarpit. Defensible, and it means an admin cannot change behavior without a developer | | **Click-to-edit visual building** | Lovable, Bolt, Base44, Retool | **Two-thirds answered, one-third a real gap.** *Instant preview* we have both ways (Vite HMR locally, a preview pane over a live devserver in the hosted builder) — but that is parity with any decent framework, not a win. *Figma* works through an MCP server or a pasted screenshot, and [Manyfold](https://substrat.net/verticals/manyfold.md) is the evidence: a 13-screen design-system handover recreated as a working vertical. It is setup, though, not a button. *Click-to-edit* we genuinely lack, and the gap is **audience, not technology** — it exists so someone with no terminal and no vocabulary for the change can still make it | | **Permissive licence** | Rails, Wasp, Medusa (MIT) | **Deliberate**, and a real adoption barrier. Contracts and the build surface are Apache-2.0; the runtime is AGPL + commercial with escrow. That is a different bargain from MIT, and some builders will simply not take it | | **A fully self-hostable *platform*** | Odoo, Frappe, Medusa, Rails, Wasp — and Convex, a hosted-backend business that still publishes its server, dashboard and CLI | **Partial, and the split is the honest bit.** The vertical *runtime* self-hosts today — published, AGPL, one dependency, [contract-tested on two adapters](https://substrat.net/reference/contract-tests.md). The multi-tenant *hosting product* — router, control plane, PR-preview forking, per-tenant database minting — is private and Cloudflare-native. Escrow answers "can we keep running"; it does not answer "can we run the platform ourselves" | | **Environment/ALM breadth** | Salesforce, Dataverse | **We win the part that matters and lose the breadth.** A per-PR fork of prod that runs the PR's own code arrives in minutes and reaps itself, where a full-copy enterprise sandbox refreshes on the order of days. What they have and we don't is everything *around* it: change sets, deployment pipelines, org-wide metadata compare | | **Ecosystem, hiring pool, twenty years of answers** | Rails, and it isn't close | **Structural.** Nothing to do but say it | **The pattern worth noticing:** almost every gap is *breadth* — catalogues, ecosystems, certifications, years. Almost every strength is *depth of guarantee*. That is the honest shape of a young platform with an unusual foundation, and it says exactly who should not buy yet: anyone whose decision turns on connector count, an admin-configurable report builder, or a certificate we don't have. ## The weaknesses that aren't a feature list - **Engine reuse across verticals has no field precedent.** Nobody has shown hardened domain engines shared across products *without forking*. Two disciplines de-risk it — engines are **extracted** from working verticals, never designed up front, and the placement rules bound what may become one — but it remains the least-proven thing here. **[built, unproven]** - **Single-vendor runtime concentration.** The hosted path is Cloudflare end to end. The adapter rule and the always-green SQLite adapter are the mitigation, not a denial. - **Design authority is concentrated.** Both human checkpoints and most of the architectural taste sit with a very small team. Escrow protects a customer's ability to keep running; it does not protect against a bus. - **Enforcement is a slow argument.** It asks a buyer to follow a claim about runtime architecture before they can price it. An inherited certification is the version a procurement officer prices in one sentence — and it isn't there yet. - **The egress sandbox has a documented hole.** Outbound traffic is bounded by a [declared per-version allowlist](https://substrat.net/concepts/platform.md), enforced at the egress seam. Durable Object subrequests are a known gap. Don't call it airtight; it isn't yet. - **One contract suite runs on one adapter.** The [conformance suite](https://substrat.net/reference/contract-tests.md) is the mechanism behind "the guarantees are properties of the substrate", and both shipped adapters pass all of it but one suite: the one that moves a clock forward past a grant's `expiresAt` and demands the denial. That needs a clock the host can be handed, and the Durable-Object host cannot take one for that judgement — grant expiry is decided inside a DO the runtime constructs. So a grant *lapsing* is proven on the self-host adapter and taken on the production one, where the same predicate runs against the wall clock. Not a known bug; an asymmetry in the evidence, and the conformance page names it rather than averaging it away. ## When Substrat is simply the wrong tool These are "no"s, not missing features. The boundary is part of the definition — the full reasoning is on [How Substrat compares](https://substrat.net/guide/comparisons.md#when-substrat-is-the-wrong-tool). - **Single-tenant internal tooling** — Retool and the low-code platforms own that shape. - **A data- or scale-heavy single tenant** — the scope-per-customer model suits many operationally-rich tenants, not one tenant with hundreds of millions of hot rows. - **Deep-domain-moat products** — accounting, payroll, core banking. Integrate; never rebuild. - **Products whose foundation isn't the binding constraint** — consumer scale, ML-first, realtime-collaboration-first, dev tools. - **Anything single-tenant and simple** — Rails or Wasp will beat us, and it isn't close. ## Why this page exists Because the product is a trust claim, and a trust claim that hides its gaps is the one thing that cannot survive being checked. If something here has changed and this page hasn't, that is a bug — [tell us](https://github.com/substrat-run/substrat/issues). --- # FAQ Source: https://substrat.net/guide/faq.md Short answers, each pointing at the page that has the long one. ## What is Substrat, in one sentence? A hosted substrate for vertical B2B SaaS: multi-tenancy, permissions, audit, and GDPR enforced **below** the API surface, so it stops mattering who — or what — wrote the code above them. → [What is Substrat?](https://substrat.net/guide/what-is-substrat.md) ## How is this different from a framework like Rails or Wasp? A framework gives you **conventions**; Substrat gives you **guarantees**. Conventions are correct the day they're written and erode with every edit after. A guarantee is a property of the runtime: there is no API that returns another tenant's data with the wrong flag set, because the API for reaching a scope *is* the isolation mechanism. For one app for one organization, take the framework — it will beat us, and it isn't close. → [How Substrat compares](https://substrat.net/guide/comparisons.md) ## How is this different from Supabase? Supabase does enforce at runtime, but the guarantee is contingent on row-level-security policies **you** write correctly — precisely the surface inexperienced builders and LLMs misconfigure most. Substrat's isolation is not a policy you author. It is also vertical-SaaS-shaped rather than app-shaped: nested tenancy, a module system, domain engines. → [Backend-as-a-service](https://substrat.net/guide/comparisons.md#backend-as-a-service) ## How is this different from Lovable, Bolt, or Base44? They generate the whole app, *including the dangerous parts*. Substrat inverts the split: the dangerous 30% is a hardened substrate; the safe, high-velocity 70% — screens, forms, workflows — is where generation happens. They're complementary, not rivals. A prompt-to-app tool pointed at Substrat's manifest generates *above* the guarantees instead of reinventing them. → [Building for AI agents](https://substrat.net/guide/ai-agents.md) ## Do I own the code? Yes, and it runs without us. The whole stack boots on the pure-SQLite adapter with no platform in the loop — that is how CI runs on every commit. Contracts and the build surface are Apache-2.0; the runtime (kernel, adapters, engines) is AGPL + commercial, with escrow. Said plainly: *"open source"* here means **copyleft and inspectable, not permissive**. If MIT is a requirement, this is a real reason to pick something else. → [What Substrat doesn't have (yet)](https://substrat.net/guide/what-substrat-lacks.md) ## Can I self-host it? Your **vertical**, yes — it is published, AGPL, has one runtime dependency, and is contract-tested on two adapters. The multi-tenant **hosting product** — router, control plane, PR previews, per-tenant database minting — is private and Cloudflare-native. Escrow answers *"can we keep running"*. It does not answer *"can we run the platform ourselves"*. That distinction is deliberate and we'd rather state it than blur it. ## Do I have to use AI to build on it? No. Substrat is an ordinary TypeScript codebase and works fine with no agent anywhere near it. It is *designed* for agents — small typed surface, mechanical pushback, self-describing manifests — because that design also makes it legible to humans. The two goals turned out to be the same goal. ## Which model does the AI tooling use? Whichever you point it at. Design and build run in *your* agent (Claude Code, Cursor, opencode) against skills that ship in the repo, and the hosted builder runs against a model you choose. Your tokens, your model, your agent. → [Building for AI agents](https://substrat.net/guide/ai-agents.md#bring-your-own-model-bring-your-own-agent) ## What can't an agent do on its own? Two things, and they hold even in a fully agent-driven shop: 1. **Schema migrations** — the blast radius of a bad one is data, not pixels. 2. **Permission definitions** — who can do what, where in the tree. Both are reviewed as a human-readable diff, and CI re-emits the permission artifact and fails on drift, so a widened role cannot merge without appearing in the pull request. → [Why runtime enforcement?](https://substrat.net/guide/why-substrat.md#the-two-human-checkpoints) ## How do I know a new version won't destroy my customers' data? You watch it happen first. Open a pull request and the platform forks the production scope, runs *that PR's code and migrations* against the copy, and posts a URL on the PR. When a bind crosses a migration-digest boundary the pre-migration state is snapshotted automatically — the digest comparison is the gate, not a flag anyone remembers to pass. Per-scope point-in-time rewind covers roughly 30 days underneath all of it. → [Environments & previews](https://substrat.net/guide/environments-and-previews.md) ## What's an "engine", and why can't I just fork one? A headless, versioned package that owns an invariant — a state machine that can't skip states, an append-only ledger, an invoice that is immutable after export. You extend it by **composition**: call its in-scope functions inside your own operation, in your own transaction, with your own permission check. You *can* fork one; nothing stops you. But if you need to, the engine drew its line wrong — that's the design test, and we'd rather hear about it. → [What is an engine?](https://substrat.net/engines/index.md) ## Why two levels of tenancy? Because one is never enough for the customers this is for, and retrofitting the second is close to a rewrite. A tenant is the customer you bill; a **scope** is the consistency domain inside them — a branch, a site, a housing association, a country. Permissions inherit down the tree; data does not leak up it. → [Tenants & scopes](https://substrat.net/concepts/tenancy.md) ## Is it production-ready? It is 0.x, and interfaces change without notice until the first vertical ships. There are verticals deployed and serving real data on it today, on custom domains. Whether that adds up to "ready" depends entirely on what you need — the honest inventory of what is missing is [here](https://substrat.net/guide/what-substrat-lacks.md), and it is not short. ## What if I'm building something Substrat is wrong for? Then use something else, and the docs will tell you so rather than sell around it. Single- tenant internal tools, scale-heavy single tenants, deep-domain products like payroll or core banking, and anything where the foundation isn't your binding constraint are all better served elsewhere. → [When Substrat is the wrong tool](https://substrat.net/guide/comparisons.md#when-substrat-is-the-wrong-tool) ## How do I start? ```sh npm create substrat my-app ``` → [Getting started](https://substrat.net/guide/getting-started.md) --- # Architecture Source: https://substrat.net/guide/architecture.md 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. **Diagram — the three-layer stack.** Six bands, top to bottom: verticals, the line, engines, the kernel, the adapters under it, and connectors at the edge. - **Verticals** — Everything a user touches — the businesses themselves. *(own vocabulary · screens · pricing · roles)* - `Callout` — field service - `Handlebar` — bike workshop - `Kallkälla` — coffee shop - `Meridian` — HR - `Manyfold` — headless CMS - **The line.** Above: AI velocity — mistakes are cosmetic (a wrong screen). Below: humans + runtime guarantees — mistakes are catastrophic (a tenant leak). - Verticals meet engines by: 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)* - `workorder` — one state machine · append-only time + material - `booking` — resource × interval × capacity · one allocation, no locks - `invoicing` — consumes billable events · immutable once exported - `protocol` — checklists + signed docs · freeze → immutable, hashed - `invites` — hashed identifier · accept-required · non-enumerable - Engines meet the kernel by: ↓ 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)* - Provides: Identity, Nested tenancy, Permissions + grants, Events / audit spine, Migrations, GDPR machinery, Notifications, Jobs, Billing entitlements, Module system, Attachment contracts, App shell. - Every operation runs inside: `ctx.sql`, `ctx.check`, `ctx.emit`, `ctx.link`. - No customer table, no work-order table. It offers attachment contracts that bind to opaque (entityType, entityId) refs the vertical defines. - Kernel meets the adapters by: 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-sqlite` — dev · CI · self-host / escrow - `adapter-cloudflare` — production · Durable-Object per scope - `adapter-email` — notification 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 eSign` — signatures-requested → BankID signing → recorded back - `…more` — one port per capability **The four rules that hold it together** 1. **Kernel owns no domain entities.** It provides the spine; verticals define what the entities mean. 2. **Star topology.** Engines cooperate through fat events and opaque refs — never by importing each other. N contracts, not N². 3. **Enforced at runtime.** Guarantees are defaults of the substrate, not config a builder — human or AI — can get wrong. 4. **No forking.** If a vertical ever needs to fork an engine, the engine drew its line wrong. ## Topology **Diagram — the topology, adapter-neutral.** Vertical code reaches data one way, and every scope feeds one spine. 1. **Vertical** (your code, TypeScript · often AI-built) reaches the host by **@substrat-run/kernel API only**. 2. **Scope host** (kernel) — `getScope(principal, tenant, scope)` returns a capability stub. holding a stub is the authorization. 3. Each scope is its own database + ACL, serialized — one op at a time — drawn here as Scope · branch #1 and Scope · branch #240, but there are as many as the tenant has. 4. Every scope emits **events, kernel-stamped** into the **Event spine** (kernel-owned): audit · 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](https://substrat.net/concepts/tenancy.md) and [Operations & the scope host](https://substrat.net/concepts/scope-host.md). ## Contracts first, adapters below Every boundary-crossing data shape is a [Zod](https://zod.dev) schema in [`@substrat-run/contracts`](https://substrat.net/reference/contracts.md) — the reviewed artifact *is* the runtime validator ("parse, don't trust"). The kernel's behavioral seams are pure TypeScript interfaces in [`@substrat-run/kernel`](https://substrat.net/reference/kernel.md) that import no platform APIs. Platform specifics live only in **adapters**: | Adapter | Backing | Use | |---|---|---| | [`@substrat-run/adapter-sqlite`](https://substrat.net/reference/adapter-sqlite.md) | one SQLite file per scope, per-scope actor | local dev, CI, self-host | | [`@substrat-run/adapter-cloudflare`](https://substrat.net/reference/adapter-cloudflare.md) | 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](https://substrat.net/reference/contract-tests.md) 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. **Diagram — the hosted runtime topology.** Every box is a Durable Object with its own SQLite; the router’s only job is to find the right door. The request descends browser → router → worker → scope, and the response returns the same way, metered at the router. 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. - **The vertical worker** runs: Kernel host (getScope() · permission gate · metering); Vertical HTTP (routes, error envelope, session → principal); Connector code (the only place fetch() is allowed to exist). - **The Scope DO** runs: Vertical operations — ctx.check(), then ctx.sql; Engine functions — the same transaction; Kernel spine — events, outbox, links, migrations. **How a request travels** 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** - **Control-plane DB** (Directory, 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. - **Identity DB** (Application / auth, 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. - **Scope DB** (Business data, 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](https://substrat.net/concepts/model.md), 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](https://substrat.net/concepts/modules.md). ## 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](https://substrat.net/engines/invoicing/index.md) consumes `workorder.completed` *and* `commerce.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 *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. --- # Getting started Source: https://substrat.net/guide/getting-started.md Two ways in, and they answer different questions. **[Scaffold a vertical](#quick-start)** is the one to take first: one command gives you a project that installs, tests green, and already carries the instruction layer your AI editor reads. It answers *"what does building on Substrat feel like?"* **[The ten-minute host](#the-ten-minute-host)** builds the same thing from the packages up — a scope host, a module, one operation, one invocation. It answers *"what is actually happening under there?"*, and it is worth doing once even if you never write a host again. > **Warning — Pre-release** > > Substrat is 0.x. Interfaces change without notice until the first vertical ships. ## Quick start {#quick-start} ```sh npm create substrat my-app cd my-app && pnpm install pnpm test ``` That last line is the point: the scaffold ships a **small working vertical** — a bike-repair shop — that is green out of the box. It is a worked example, not a skeleton to fill in, and the build flow reshapes it into your domain rather than asking you to start from a blank file. Then open the project in your AI editor and start the flow: | editor | command | |---|---| | Claude Code | `/substrat` | | Cursor | the `new-vertical` command | | opencode | the `new-vertical` command | All three read the same two files the scaffolder wrote: `AGENTS.md` (the rules an agent must not violate) and `.substrat/playbook.md` (the build flow). The scaffolder writes the skeleton; **the agent writes the vertical**. ### What you got ``` src/entities.ts what exists — each entity's table, row shape, key and parents src/operations.ts the operations: input, output, permission, and their http route src/manifest.ts what the module declares — permissions, events, entitlement key src/migrations.ts the append-only journal src/module.ts the operation handlers, bound to operations.ts src/provision.ts roles and the permission surface `substrat push` reads src/seed.ts a world to develop against src/personas.ts the local dev cast you sign in as src/routes.ts the HTTP API — operation routes derived from operations.ts src/server.ts a Node dev server (SQLite adapter) src/worker.ts the Cloudflare entry, via @substrat-run/vertical-host src/config-do.ts per-instance settings (Cloudflare only) test/scenario.test.ts · test/entities.test.ts AGENTS.md · .substrat/playbook.md · .claude/ · .cursor/ · .opencode/ ``` An operation route is never written by hand: declare `http` on the operation in `src/operations.ts` and both `server.ts` and `worker.ts` serve it. What stays hand-written is only what the operations do not declare — the generic `POST /api/invoke` transport in `routes.ts`, and each entrypoint's own auth routes (`/api/auth/*` and `/api/me` in `server.ts`, `/api/me` in `worker.ts`). The one line `src/worker.ts` must keep first is its `invocationLog` mount — see [create-substrat](https://substrat.net/reference/create-substrat.md#signing-in-locally). There is no `wrangler.jsonc` and you never write one. The `substrat` block in `package.json` declares what the deploy needs — the entry, the durable-object stores — and [`substrat push`](https://substrat.net/guide/deploying.md) derives the rest. ### The gates ```sh pnpm test # the scenario, including the denials pnpm lint:boundaries # the layer rules pnpm typecheck ``` `lint:boundaries` is the one to run early and often: it is the mechanical enforcement of the [module code rules](https://substrat.net/concepts/modules.md) — no raw database imports, no `fetch`, no `node:*`, no writes to the spine, no reading another module's tables. It fails the build rather than leaving a note in a review. > **Tip — Never install `zod`** > > Substrat is on Zod 4, and **Zod schemas do not compose across copies or majors**. Mix two and > `z.object({ facility: entityRef })` — the pattern every engine uses — fails at runtime with > `expected a Zod schema`, pointing nowhere near the cause. Import `z` from contracts and the > versions cannot diverge: > > ```ts > import { z, entityRef, money } from '@substrat-run/contracts'; > ``` --- ## The ten-minute host {#the-ten-minute-host} Now the same thing from below: a scope host on pure SQLite, a module with a migration and one operation, and an invocation through a capability stub. No cloud account, no services — one directory of `.sqlite` files. ```sh pnpm add @substrat-run/kernel @substrat-run/contracts @substrat-run/adapter-sqlite ``` `@substrat-run/adapter-sqlite` uses [better-sqlite3](https://www.npmjs.com/package/better-sqlite3), a native module. With pnpm 10+, allow its build script: ```jsonc // package.json { "pnpm": { "onlyBuiltDependencies": ["better-sqlite3"] } } ``` ### 1. Create a host and provision a scope ```ts import { SqliteScopeHost } from '@substrat-run/adapter-sqlite'; import { UNSAFE_allowAllChecker } from '@substrat-run/kernel'; import { tenantId, scopeId, principalId, platformActorId } from '@substrat-run/contracts'; const host = new SqliteScopeHost({ dir: './data', // one .sqlite file per scope + _directory.sqlite checker: UNSAFE_allowAllChecker, // omit for the secure default: deny everything }); const actor = platformActorId.parse('01JZX6ZH2E8Q4W9T3M5N7P0R2T'); // control-plane staff subject const tenant = tenantId.parse('01JZX6ZH2E8Q4W9T3M5N7P0R2S'); const scope = scopeId.parse('01JZX6ZH2EAB4CD9EF3GH5JK2M'); // A scope belongs to a tenant, so the tenant record comes first. Entitlements are // default-deny: grant the module's SKU flag or its operations won't resolve. host.admin.createTenant(actor, { id: tenant, slug: 'acme', name: 'Acme' }); host.admin.grantEntitlement(actor, tenant, 'notes'); await host.provisionScope(actor, { tenantId: tenant, scopeId: scope, jurisdiction: 'eu' }); ``` Every control-plane call takes a platform `actor` and is audited. Provisioning is idempotent and journaled, and requires an existing active tenant. The `jurisdiction` is fixed at creation, forever — data residency is a provisioning decision, not a runtime flag. > **Tip — The checker choice is the security posture** > > `UNSAFE_allowAllChecker` grants everything to everyone and is named accordingly — use it > in tests and scratch scripts only. Omit the option and you get the real tuple checker, > which denies by default until roles and grants say otherwise. See > [Permissions](https://substrat.net/concepts/permissions.md). ### 2. Register a module A module is a manifest + migrations + operations, and — optionally, though every engine and vertical in this repo does it — the schemas the host parses those operations against. Here's a minimal one (engines ship this structure for you — see [What is an engine?](https://substrat.net/engines/index.md)): ```ts import { z, moduleManifest } from '@substrat-run/contracts'; import { assertAllowed, ulid, type ModuleRegistration } from '@substrat-run/kernel'; const noteInput = z.object({ text: z.string().min(1) }); export const notesModule: ModuleRegistration = { manifest: moduleManifest.parse({ id: '@acme/notes', version: '0.0.1', kernelContract: '^0.0.1', permissions: [ { key: 'notes:write', description: 'Create notes' }, ], events: { emits: [{ type: 'notes.created', schemaVersion: 1 }], consumes: [], }, migrations: { journalDir: './migrations', compatibleFrom: '0.0.1' }, attachmentTargets: [], entitlementKey: 'notes', }), migrations: [ { version: '0001-init', sql: `CREATE TABLE notes ( id TEXT PRIMARY KEY NOT NULL, text TEXT NOT NULL, created_by TEXT NOT NULL, created_at TEXT NOT NULL );`, }, ], operations: { 'notes/create': async (ctx, { text }: z.infer) => { assertAllowed(await ctx.check('notes:write' as never)); const id = ulid(); ctx.sql.exec( 'INSERT INTO notes (id, text, created_by, created_at) VALUES (?, ?, ?, ?)', [id, text, ctx.principal, ctx.now()], ); ctx.emit({ type: 'notes.created', schemaVersion: 1, entity: { entityType: 'note', entityId: id }, piiClass: 'none', payload: { noteId: id }, }); return { id }; }, }, operationInputs: { 'notes/create': noteInput }, }; host.registerModule(notesModule); ``` Things to notice: - **The host parses the input, not the handler.** `operationInputs` names the schema for each operation, and the host applies it before the guards and the handler on every path in — HTTP, in-process `invoke`, a seed, a schedule. A handler that parsed its own input would be a trust boundary each new operation has to remember; this one cannot be forgotten. A vertical with a declared operation surface hands the whole map over at once with `operationInputsOf(ops)` rather than listing names — see [Modules & the manifest](https://substrat.net/concepts/modules.md#the-parse-the-host-owns). Leaving the map out is legal and means nothing was declared to parse, so the Zod object above would be compile-time only. - **The permission check is the first line.** `assertAllowed` throws `PermissionDenied` unless the decision is an allow. - **`ctx.emit` takes no origin fields.** Tenant, scope, actor, id, and timestamp are stamped by the kernel; your code physically cannot mislabel an event. - **The row's timestamp is `ctx.now()`, not `new Date()`.** Module code has no clock of its own — `ctx.now()` is an ISO 8601 instant, stable for the whole invocation, so the row and the event announcing it agree about when. `new Date()` / `Date.now()` in module code is a [boundary-lint R6](https://substrat.net/reference/boundary-lint.md) violation, and a scaffolded project fails its own gate on it. - **Migrations apply lazily per scope**, journaled, inside the scope's serialization domain — you never run a migration step yourself. - **`id TEXT PRIMARY KEY NOT NULL`, not `id TEXT PRIMARY KEY`.** In SQLite a non-INTEGER primary key does not imply `NOT NULL`. In a real vertical you would not write the DDL at all — [`emitTables`](https://substrat.net/reference/model-emit.md) derives it from your declared entities and cannot produce that hole. ### 3. Invoke through a stub ```ts const principal = principalId.parse('01JZX6ZH2EXY4ZA9BC3DE5FG2H'); const stub = await host.getScope(principal, tenant, scope); const { id } = await stub.invoke<{ id: string }>('notes/create', { text: 'first note', }); await host.close(); ``` `getScope` validates the `(tenantId, scopeId)` pair against the directory. A mismatched pair **throws** — it never resolves to another tenant's scope, so a confused-deputy bug in calling code fails closed instead of leaking data. The stub is a capability: it carries the principal and the scope context, so the operation handler receives ambient `ctx.tenantId` / `ctx.scopeId` / `ctx.principal` and no IDs travel through your business logic. ### 4. Look at what happened Scope databases are plain SQLite files in WAL mode — debugging is opening a file: ```sh sqlite3 ./data/__.sqlite 'SELECT * FROM notes;' sqlite3 ./data/__.sqlite 'SELECT type, tenant_id, actor, occurred_at FROM _substrat_outbox;' ``` The event row carries the full kernel-stamped envelope — that's your audit trail, produced as a side effect of the write path rather than as something you remembered to log. Nothing in the code above asked for it. From inside an operation you read the same rows through [`readTimeline`](https://substrat.net/concepts/events.md#reading-one-entity-s-history) rather than by hand — it decodes `actor` out of the union the column stores it as, and pages on the event id. ## Next steps - [Running locally](https://substrat.net/guide/running-locally.md) — the development loop, the demos, the personas - [Deploying a vertical](https://substrat.net/guide/deploying.md) — the `substrat` CLI, `push`, and the admission gate - [The model](https://substrat.net/concepts/model.md) — declaring entities and operations once, and what the compiler then checks between them - [Tenants & scopes](https://substrat.net/concepts/tenancy.md) — the tenancy tree and how scopes are addressed - [Permissions](https://substrat.net/concepts/permissions.md) — roles, grants, and proof-carrying decisions - [Events & audit](https://substrat.net/concepts/events.md) — the envelope, PII classes, and consumers - [What is an engine?](https://substrat.net/engines/index.md) — using the work-order and invoicing engines instead of writing your own machinery - [`@substrat-run/contract-tests`](https://substrat.net/reference/contract-tests.md) — if you're writing an adapter rather than a vertical --- # Walkthrough: the todo app Source: https://substrat.net/guide/walkthrough-todo.md This page shows the whole process once, on the smallest app anyone could ask for — *"I want to create a todo app"* — using **complete files from the repository** rather than excerpts. An excerpt of generated code is unconvincing, because the reader assumes the messy parts were cropped. Every block below is the file as it sits in `demos/todo/` at the version of these docs, pulled in at build time; nothing is retyped for the page. The one thing dropped is the title line of each markdown file — the section heading above the block stands in for it, so the page keeps a single top-level heading — and everything from the first line after it onward is shown. Todo was chosen for the reason a todo app is usually a bad demo: the reader spends zero attention on the domain, so the *process* is the only thing on the page. Every other [demo vertical](https://substrat.net/verticals/index.md) makes you learn field service or bike repair first. The chain is five files and one derivation step: | step | file | who writes it | |---|---|---| | 1. The brief | `spec/prompt.md` | the customer, in one line | | 2. The interview | `spec/answers.md` | the customer, answering the builder | | 3. The concept | `spec/concept.md` | the builder; approved before any code | | 4. The model | `spec/model.ts` | the builder — what exists, declared once | | 5. What is derived | migrations, manifest, permissions, API, client | nobody — re-emitted, and gated | | 6. The business logic | `src/module.ts` | the builder — the only code that is *about* todo | Read it top to bottom. The point is at the end: the last file is the whole of the application code, and none of the tenancy, permissions or audit it runs under appears in it. ## 1. The brief The prompt is one line, deliberately. A real brief is underspecified, and everything that follows was either asked for in the interview or assumed by the builder and written down in the concept's Assumptions section — never silently. > I want to create a todo app. That is the whole prompt, deliberately. A real brief is underspecified — everything below it in `spec/` was either asked for in the interview (see `answers.md`) or assumed by the builder and recorded in the concept's Assumptions section. Keep this file short. Intent enters here; derived decisions belong in `concept.md`. If a correction cannot be expressed as something a customer would actually have said, that is a finding about the pipeline, not a reason to grow this file. ## 2. The interview The [design skill](https://substrat.net/guide/agent-plugin.md) asks the questions the brief left open, in two rounds of a few each, recommending an answer to every one. Here every recommendation was accepted as offered, which is what a builder should expect for an app this plain. The interview asked two rounds, five questions. Delivered as one block so a replay does not have to match question wording — the interview skill reads this, finds its frontier covered, and proposes. - **Who uses it:** I share lists with people. I own my lists and can invite specific people to some of them; the rest stay private. - **Screens:** a web UI. - **Anything automatic:** no. Nothing on a timer — no due dates, no reminders, no recurring tasks. - **What a share allows:** they can add items and tick them off, but not delete the list or share it onward. - **Accounts:** I invite them by email. Nobody signs themselves up. Every one of the five was the builder's recommended answer, accepted as offered. ## 3. The concept The interview produces a **concept** — the design document the customer approves before a line of code is written. It is written for the customer, in their vocabulary, and it is the contract every later step is checked against: the cast in §6 becomes the seed, the scenario in §9 becomes the test, and the assumptions in §10 are the decisions taken on the customer's behalf, listed so they can be reversed while reversing is cheap. Notice §3. Accounts, invitations, isolation between accounts, a permission check on every operation and an audit trail are listed under *free from the platform*. What is left under *yours to build* is lists, items and sharing — and that is the whole build. ## 1. What we're building & who uses it A todo app. Anyone with an account owns lists; a list is private until its owner shares it with someone by email. There are two kinds of people, and they use the same app: the person who owns a list, and the person it was shared with. ## 2. The thing that moves through the system Two nouns — a **list**, and the **items** on it. An item is open or done, and it can go back; that is the entire lifecycle. There are no stages to skip because there are no stages. This is a record, not a workflow. It composes no engine, and that is a fact about the app rather than an omission. ## 3. What already exists vs. what's yours **Free from the platform:** accounts and login, invitations, isolation between accounts, a permission check on every operation, an audit trail of every change, the database and its migrations, hosting. **Yours to build:** lists, items, sharing. That is the whole build. No engine is composed — nothing here has invariants beyond "you cannot touch a list nobody shared with you", and that is the platform's own. ## 4. Who can do what **The owner** does everything to their own list: create it, rename it, delete it, add items, complete them, delete them, share it, revoke a share. **Someone the list is shared with** can see it, add items, and tick items off — including un-ticking. They cannot delete items, delete the list, or share it onward. **Everyone else** sees nothing. A list you have not been shared with is invisible: not its contents, not its name, not the fact that it exists. Asking for it directly by id is refused, not answered with an empty result. **Another account entirely** reaches nothing at all. Nobody can see money, because there is none. ## 5. Money & sign-off Neither. Nothing is invoiced, quoted or paid for, and nothing needs approving before a step can happen. ## 6. The cast, roles, tenancy **Two tenants, always** — the second exists to be attacked, which is how isolation is proven rather than claimed. | who | where | what they are | |---|---|---| | Ada | tenant one | owns lists | | Björn | tenant one | Ada shares a list with him | | Cleo | tenant two | unrelated account; must reach nothing | One role: **member** — anyone with an account can own lists. Sharing is per-list, not a role, which is the interesting part of this app's permission model: what Björn may do is decided by the share on *that list*, not by anything about Björn. ## 7. The data we'll store - **People** — one row per person with an account here: who they are, when they joined. Lists hang off it, which is how "your lists" is a fact the system can follow rather than a filter the code has to remember. - **Lists** — name, who owns it, when it was created. - **Items** — which list they are on, what they say, done or not, who added them, when. - **Shares** — which list, which person, and the email it was sent to. The email is load-bearing. The sharing screen promises "shared with björn@example.com", and accounts are opaque ids, so the address needs a table of its own to come from. A promised human-readable string with no source table is a missing table. ## 8. The screens - **Your lists** — the ones you own and the ones shared with you, marked which is which. - **A list** — its items, tick to complete, add an item. - **Sharing** — enter an email, see who the list is shared with, revoke. ## 9. The scenario the test will replay **The happy path.** Ada creates "Groceries" and adds milk. She shares it with Björn. Björn sees the list, adds bread, and ticks milk off. Ada sees both changes. **The denials that prove it.** - Björn cannot delete the list, and is refused when he tries. - Ada's other list, "Work", is not shared. It does not appear in Björn's lists, and asking for it directly by id is refused. - Cleo reaches nothing at all — not Ada's lists, not Björn's, not by id. - A control alongside each: Björn *can* add and tick, so the closed doors are not passing because every door is shut. ## 10. Assumptions Decisions taken on the builder's behalf. Each is cheap to reverse now and expensive later. - The product is called **Todo**. - People you share with can add and complete, but cannot delete items or the list, and cannot share it onward. - Ticking is reversible — completing something is not final. - One role: anyone with an account can own lists. Sharing is per-list. - Everyone uses the same app; there is no separate portal for people you share with. - A list is shared with a **person**, not with a public link. - Revoking a share is possible, and takes effect immediately. - Item text is ordinary personal data — nothing needing erasure beyond deleting it. - English only. ## 11. Out of scope Due dates, reminders and recurrence (the builder said no). Sub-tasks, attachments, comments, re-sharing, public links, search, and a mobile app. ## 4. The model The approved concept becomes one TypeScript module declaring **what exists**: the entities and their fields, the operations over them, and the permission each operation checks. It is TypeScript rather than a schema language because the compiler checks the joins between those three things — an operation naming an entity that does not exist, an `entityIdFrom` naming no output field, a payload carrying an `erasable` field — and those joins are where the defects live. [The model](https://substrat.net/concepts/model.md) explains each declaration; this is what a complete one looks like. Two lines carry the permission model. `parents: ['owner']` on `list` is the edge the permission walk follows, so "your lists" is a fact the kernel can prove rather than a filter the code has to remember. And `permission: { key: 'list:manage', entity: 'list', idFrom: 'listId' }` on `rename-list` declares that the check is *on that list*, which the [conformance kit](https://substrat.net/reference/contract-tests.md) then drives against the handler. ```ts /** * Todo's model — what exists, declared once (#697/#707). * * The concept is approved (`spec/concept.md`); this is its entity and operation * surface, and everything downstream is derived from it: the migrations, the * manifest, the route table, the permission registry, the API document. * * This vertical composes **no engine**. Nothing here has an invariant beyond * "you cannot touch a list nobody shared with you", and that one is the * platform's. An absent `engines` argument is a fact about the app, not an * omission. */ import { defineEntities, defineOperations, emitModel, z, } from '@substrat-run/contracts'; import { MAX_SEARCH_LIMIT } from '@substrat-run/kernel'; /** * How much wider than the answer the handlers ask the index for. * * Every search here filters hits AFTER ranking — by list, or by which lists the * caller reaches — and a ranked top-N filtered afterwards returns fewer than N. * Asking for more is the only defence. */ export const SEARCH_OVERFETCH = 4; /** * This vertical's own cap, and deliberately NOT the kernel's. * * `ctx.search` clamps every ask to `MAX_SEARCH_LIMIT`, so a caller allowed to * request the ceiling leaves the over-fetch no headroom at all: the ask and the * answer become the same number and the filter above eats into the result. Deriving * the bound keeps the two from drifting apart — raise the ceiling and this follows. */ export const TODO_SEARCH_MAX = Math.floor(MAX_SEARCH_LIMIT / SEARCH_OVERFETCH); export const todoEntities = defineEntities({ /** * A person who has an account in this scope — the thing their lists hang off. * * It exists because ownership has to be reachable by the permission walk. A * list's owner cannot be a grant made by the operation that creates it: * `ctx.grant` delegates what the caller already holds, and nobody holds * anything on a list that did not exist a moment ago. So the grant is made * once, per person, on THIS entity when they join, and every list they create * links to it. The walk reaches list → owner and the answer follows. * * `id` is the principal id. One row per person, not per login. */ owner: { table: 'todo_owners', fields: z.object({ id: z.string(), email: z.string(), display_name: z.string(), created_at: z.string(), }), key: ['email'], erasable: ['email', 'display_name'], }, /** A list. Private to its owner until a share exists for someone else. */ list: { table: 'todo_lists', fields: z.object({ id: z.string(), owner_id: z.string(), name: z.string(), created_at: z.string(), }), parents: ['owner'], }, /** * An item on a list. `done` is a number because SQLite has no boolean and the * row comes back as 0/1 — declaring `z.boolean()` here would emit the same * INTEGER column while promising the reader a type the database cannot return. */ item: { table: 'todo_items', fields: z.object({ id: z.string(), list_id: z.string(), text: z.string(), done: z.number(), added_by: z.string(), created_at: z.string(), }), parents: ['list'], }, /** * One person's access to one list. * * The email is here because the sharing screen promises "shared with * björn@example.com" and principals are opaque ids — a promised string with no * source table is a missing table. It is the only directly personal field in * the app, so it is `erasable`, which also makes it uncarryable by any event. * * `key` is the composite it reads as: one share per person per list. */ share: { table: 'todo_shares', fields: z.object({ id: z.string(), list_id: z.string(), principal: z.string(), email: z.string(), created_at: z.string(), }), parents: ['list'], key: ['list_id', 'principal'], erasable: ['email'], }, }); /** * Three keys, and the split is the app's whole permission model. * * - `list:create` is held scope-wide by every member. It is the only one that * can be, because creating a list is the one act with no entity to narrow to. * - `list:manage` and `list:contribute` are granted per person on their OWN * `owner` entity when they join, and reach their lists through the declared * parent edge. Nobody holds either one scope-wide, which is what makes one * member's lists unreachable to another. * - Sharing narrows `list:contribute` onto ONE list for one person, so what * Björn may do is a fact about *that list* rather than about Björn. */ export const TODO_PERMISSIONS = ['list:create', 'list:manage', 'list:contribute'] as const; export const todoOperations = defineOperations(todoEntities, TODO_PERMISSIONS)({ /** * Claim your account in this scope — the row every list hangs off. * * It exists because ownership is an entity, and an entity needs a moment it * comes into being. Self-service and idempotent: joining twice is joining. */ 'todo/join': { summary: 'Claim your account in this workspace', permission: 'list:create', input: z.object({ email: z.string().email(), displayName: z.string().min(1) }), output: todoEntities.owner.fields, http: { method: 'POST', path: '/join' }, emits: { entity: 'owner', entityIdFrom: 'id', type: 'todo.owner-joined', schemaVersion: 1, piiClass: 'pseudonymous', subjectId: 'id', // `email` and `display_name` are erasable, so neither can ride here. payload: ['id'], }, }, 'todo/create-list': { summary: 'Create a list', permission: 'list:create', input: z.object({ name: z.string().min(1) }), output: todoEntities.list.fields, http: { method: 'POST', path: '/lists' }, emits: { entity: 'list', entityIdFrom: 'id', type: 'todo.list-created', schemaVersion: 1, piiClass: 'none', payload: ['id', 'name', 'owner_id'], }, }, /** * The proof walk. Owned lists and shared-with lists arrive in one answer, and * a list nobody shared is not in it — filtered by a per-entity check, never by * a WHERE clause on ownership. */ 'todo/my-lists': { summary: 'List the lists you own or have been shared', narrows: { reason: 'Returns only lists the caller owns or has been shared', checks: ['list:contribute'], }, output: todoEntities.list.fields, // #811. The underlying walk IS kernel-composed — `created_at` with the id // tie-break is exactly the `ORDER BY created_at, id` this shipped with — but // visibility is decided by a per-row proof walk on top of it, so the handler // over-fetches with `pageVisible`. Pages may come back short; the walk ends at // the absent `Link`. paged: { over: { entity: 'list', sortable: ['created_at', 'name'] } }, http: { method: 'GET', path: '/lists' }, }, 'todo/rename-list': { summary: 'Rename a list', permission: { key: 'list:manage', entity: 'list', idFrom: 'listId' }, input: z.object({ listId: z.string(), name: z.string().min(1) }), output: todoEntities.list.fields, http: { method: 'PATCH', path: '/lists/{listId}' }, emits: { entity: 'list', entityIdFrom: 'id', type: 'todo.list-renamed', schemaVersion: 1, piiClass: 'none', payload: ['id', 'name'], }, }, 'todo/delete-list': { summary: 'Delete a list and everything on it', permission: { key: 'list:manage', entity: 'list', idFrom: 'listId' }, input: z.object({ listId: z.string() }), output: z.object({ id: z.string(), deleted: z.boolean() }), http: { method: 'DELETE', path: '/lists/{listId}' }, emits: { entity: 'list', entityIdFrom: 'id', type: 'todo.list-deleted', schemaVersion: 1, piiClass: 'none', payload: ['id'], }, }, 'todo/list-items': { summary: 'The items on a list', permission: { key: 'list:contribute', entity: 'list', idFrom: 'listId' }, // `limit` and `cursor` are NOT declared here: since #811 the platform parses // the page trio with the one shared schema and merges it into the input, so // the default and the `LIST_PAGE_MAX` ceiling are true of every paged read // rather than of the ones whose author remembered to restate them. input: z.object({ listId: z.string() }), // The ENTRY, not the envelope: `paged` is what wraps it, here and in the document. output: todoEntities.item.fields, // A list's items are the one table here that grows without bound — one per line of // shopping, forever. Keyset over the ULID id, which is creation-ordered for free. // `total` because the app renders a count beside the list; it costs a second // query per request, which is why it is asked for rather than assumed. paged: { sortKey: 'id', total: true }, http: { method: 'GET', path: '/lists/{listId}/items' }, }, /** * Search on ONE list (#827) — the read that `paged` above took away. * * Filtering `list-items` in the browser searched whatever page had loaded, which * at forty items looked like search and at four thousand looked like a bug. This * asks the index instead. * * A separate operation rather than a `q` on `list-items`: that read is sorted and * paged, this one is ranked and capped, and one endpoint cannot carry both * contracts. `GET /lists/{listId}/items/search` does not collide with the paged * read — `mountOperations` registers a static segment ahead of its parameter * sibling (#785). * * Permission is the ordinary entity-narrowed one: the caller either reaches this * list or they do not, and one check settles it. */ 'todo/search-list-items': { summary: 'Find items on a list by text', permission: { key: 'list:contribute', entity: 'list', idFrom: 'listId' }, input: z.object({ listId: z.string(), // Two characters is the prefix index's floor. Declared here so a short term is // a 400 naming the field, not a throw from inside the kernel. q: z.string().min(2), limit: z.number().int().positive().max(TODO_SEARCH_MAX).optional(), }), // Not a bare array: a capped read has to say it was capped, or the screen shows // the first twenty of two hundred matches as though that were all of them. output: z.object({ results: z.array(todoEntities.item.fields), limit: z.number().int(), capped: z.boolean(), }), http: { method: 'GET', path: '/lists/{listId}/items/search' }, }, /** * Search across every list the caller can reach — "where did I put milk?". * * The same `narrows` shape as `my-lists`, and for the same reason: nobody holds * `list:contribute` scope-wide, so the answer is assembled by asking, per list, * whether this caller reaches it. The index is scope-wide and checks nothing — * `ctx.search` never does — so the filter after it is what keeps one member's * items out of another's results. * * The trap here is real and documented (concepts/reads.md): a ranked top-N * filtered afterwards returns FEWER than N. The handler over-fetches on purpose. */ 'todo/search-items': { summary: 'Find items across the lists you can see', narrows: { reason: 'Returns only items on lists the caller owns or has been shared', checks: ['list:contribute'], }, input: z.object({ q: z.string().min(2), limit: z.number().int().positive().max(TODO_SEARCH_MAX).optional(), }), output: z.object({ results: z.array(todoEntities.item.fields), limit: z.number().int(), capped: z.boolean(), }), http: { method: 'GET', path: '/items/search' }, }, 'todo/add-item': { summary: 'Add an item to a list', permission: { key: 'list:contribute', entity: 'list', idFrom: 'listId' }, input: z.object({ listId: z.string(), text: z.string().min(1) }), output: todoEntities.item.fields, http: { method: 'POST', path: '/lists/{listId}/items' }, emits: { entity: 'item', entityIdFrom: 'id', type: 'todo.item-added', schemaVersion: 1, piiClass: 'none', payload: ['id', 'list_id', 'text', 'added_by'], }, }, /** Ticking is reversible, so this sets the state rather than completing. */ 'todo/set-item-done': { summary: 'Tick an item off, or put it back', // The id is not in the input: the check is on the LIST this item sits on. permission: { key: 'list:contribute', entity: 'list', resolved: 'the list the item is on' }, input: z.object({ itemId: z.string(), done: z.boolean() }), output: todoEntities.item.fields, http: { method: 'POST', path: '/items/{itemId}/done' }, emits: { entity: 'item', entityIdFrom: 'id', type: 'todo.item-done-changed', schemaVersion: 1, piiClass: 'none', payload: ['id', 'list_id', 'done'], }, }, 'todo/delete-item': { summary: 'Delete an item', permission: { key: 'list:manage', entity: 'list', resolved: 'the list the item is on' }, input: z.object({ itemId: z.string() }), output: z.object({ id: z.string(), deleted: z.boolean() }), http: { method: 'DELETE', path: '/items/{itemId}' }, emits: { entity: 'item', entityIdFrom: 'id', type: 'todo.item-deleted', schemaVersion: 1, piiClass: 'none', payload: ['id'], }, }, /** * Sharing. The event is about a share, whose subject is a person, so it is * classified and keyed — and the address itself cannot ride along, because * `share.email` is erasable. `principal` is what an erasure would key on. */ 'todo/share-list': { summary: 'Share a list with someone by email', permission: { key: 'list:manage', entity: 'list', idFrom: 'listId' }, input: z.object({ listId: z.string(), email: z.string().email() }), output: todoEntities.share.fields, http: { method: 'POST', path: '/lists/{listId}/shares' }, emits: { entity: 'share', entityIdFrom: 'id', type: 'todo.list-shared', schemaVersion: 1, piiClass: 'pseudonymous', subjectId: 'principal', payload: ['id', 'list_id', 'principal'], }, }, /** * Who a list is shared with. Owner-only: the members of a share are the * owner's business, not each other's. */ 'todo/list-shares': { summary: 'Who this list is shared with', permission: { key: 'list:manage', entity: 'list', idFrom: 'listId' }, input: z.object({ listId: z.string() }), output: todoEntities.share.fields, // A share list is short in every plausible app and unbounded all the same — // one row per person a list is shared with, and nothing caps that. `list_id` // is the filter the route already narrows by, declared so the kernel composes // it and indexes `(list_id, created_at, id)` behind it. paged: { over: { entity: 'share', sortable: ['created_at'], filterable: ['list_id'] } }, http: { method: 'GET', path: '/lists/{listId}/shares' }, }, 'todo/revoke-share': { summary: 'Revoke someone’s access to a list', permission: { key: 'list:manage', entity: 'list', resolved: 'the list the share is on' }, input: z.object({ shareId: z.string() }), output: z.object({ id: z.string(), revoked: z.boolean() }), http: { method: 'DELETE', path: '/shares/{shareId}' }, emits: { entity: 'share', entityIdFrom: 'id', type: 'todo.share-revoked', schemaVersion: 1, piiClass: 'none', payload: ['id'], }, }, }); export const todoModel = emitModel(todoEntities); ``` ## 5. What is derived From `spec/model.ts`, without another line being written: - **The migrations** — `src/migrations.generated.ts`, the `CREATE TABLE` for each entity and the indexes behind each declared sort and filter, re-emitted by `pnpm lint:migrations`. - **The manifest** — assembled from the model's two halves at mount time: the permission registry, the entity relations the permission walk follows, the events each operation emits, the paged lists. - **`PERMISSIONS.md`** — the human checkpoint: every key, what it means, and which role holds it, rendered by `pnpm lint:permissions` and checked in, so a widened role cannot merge without appearing in the diff. - **`model.json`** — the model as data, for tools that do not run TypeScript; gated by `pnpm lint:model --check`. - **The HTTP surface** — each operation's `http: { method, path }` becomes a route, its input schema becomes the request validation the host applies before the handler runs, and `openapi.json` with a Scalar page is served from the same catalogue (`pnpm lint:api`). - **The browser client** — `app/src/api.generated.ts`: typed methods for every operation, the entity interfaces, the paged `Link` walk (`pnpm lint:client`). - **`CONFORMANCE.md`** — the receipt saying which declared entity checks the kit drives against the running handler, and which it cannot, by name (`pnpm lint:conformance`). Each of those carries the three marks a [generated file](https://substrat.net/guide/agent-rules.md) must: the `.generated` suffix or a `GENERATED` first line, a header naming the producer and the source, and a `--check` re-emit in CI. The third is the only one that enforces anything. ## 6. The business logic What is left is the one file that is *about* todo: what it means to share a list, and who may do what to one. Every handler opens with the check its declaration promised, and the `satisfies` clause at the bottom is what makes a handler that disagrees with its declaration — or one declared and not implemented — a compile error naming the method. Sharing is the part worth reading twice. `share-list` is one `ctx.grant` and `revoke-share` is one `ctx.revoke`: the kernel narrows `list:contribute` onto *this* list for *this* person, re-checks that the caller holds it there, and records it transactionally with the operation. Nothing else in the app has to remember that Björn may touch this list — the grant is the fact, and every check reads it. ```ts /** * Todo's operations — the business logic, and nothing else. * * Everything structural is derived from `spec/model.ts`: the migrations were * emitted from the entities, the manifest is assembled from both halves of the * model, and the route table is derived at mount time. What is left in this file * is what only a person could decide — what it MEANS to share a list, and who * may do what to one. * * `satisfies OperationImpl<…>` is the join: a handler whose input or return * disagrees with the declared operation, one declared and not implemented, or * one implemented and not declared, is a compile error naming the exact method. */ import { countedPageOf, pageVisible, dataSubjectId, LIST_PAGE_DEFAULT, operationInputsOf, substratError, type HandlerInput, type HandlerOutput, z, type EntityRow, type PrincipalId, } from '@substrat-run/contracts'; import { assertAllowed, DEFAULT_SEARCH_LIMIT, MAX_SEARCH_LIMIT, ulid, type ModuleRegistration, type OperationContext, type OperationHandler, } from '@substrat-run/kernel'; import { SEARCH_OVERFETCH, todoEntities, todoOperations } from '../spec/model.js'; import { TODO_PERM, todoManifest } from './manifest.js'; import { todoMigrations } from './migrations.generated.js'; type OwnerRow = EntityRow; type ListRow = EntityRow; type ItemRow = EntityRow; type ShareRow = EntityRow; const listRef = (id: string) => ({ entityType: 'list', entityId: id }); /** * Ask the index for more than the caller wants (#827). * * `SEARCH_OVERFETCH` is a guess, and an honest one: it is not a guarantee, which is * why both handlers still report `capped` rather than pretending the answer is * complete. The `min` is a backstop rather than the design — `TODO_SEARCH_MAX` is * derived so that the widened ask stays inside the kernel's ceiling, and the clamp * only ever fires if that derivation is broken. */ const overfetch = (limit: number) => Math.min(limit * SEARCH_OVERFETCH, MAX_SEARCH_LIMIT); /** Hits carry the rank; rows carry the shape. Re-join them in the index's order. */ function rankOrder(hits: readonly { id: string }[], rows: readonly ItemRow[]): ItemRow[] { const byId = new Map(rows.map((row) => [row.id, row])); return hits.map((hit) => byId.get(hit.id)).filter((row): row is ItemRow => row !== undefined); } /** The list, or a refusal — never a silent empty answer. */ function listOrThrow(ctx: OperationContext, id: string): ListRow { const row = ctx.sql.query('SELECT * FROM todo_lists WHERE id = ?', [id])[0]; if (!row) throw substratError('not_found', `list not found: ${id}`); return row; } /** The list an item sits on — every item permission is really the list's. */ function itemAndList(ctx: OperationContext, itemId: string): { item: ItemRow; list: ListRow } { const item = ctx.sql.query('SELECT * FROM todo_items WHERE id = ?', [itemId])[0]; if (!item) throw substratError('not_found', `item not found: ${itemId}`); return { item, list: listOrThrow(ctx, item.list_id) }; } const operations = { 'todo/join': async (ctx, input) => { assertAllowed(await ctx.check(TODO_PERM.listCreate)); const existing = ctx.sql.query('SELECT * FROM todo_owners WHERE id = ?', [ ctx.principal, ])[0]; if (existing) return existing; ctx.sql.exec( 'INSERT INTO todo_owners (id, email, display_name, created_at) VALUES (?, ?, ?, ?)', [ctx.principal, input.email, input.displayName, ctx.now()], ); const row = ctx.sql.query('SELECT * FROM todo_owners WHERE id = ?', [ ctx.principal, ])[0]!; ctx.emit({ type: 'todo.owner-joined', schemaVersion: 1, entity: { entityType: 'owner', entityId: row.id }, piiClass: 'pseudonymous', subjectId: dataSubjectId.parse(row.id), payload: { id: row.id }, }); return row; }, 'todo/create-list': async (ctx, input) => { assertAllowed(await ctx.check(TODO_PERM.listCreate)); // The person's own entity must exist: it is what their lists hang off, and // what carries the grant that reaches them. Created when they join. const owner = ctx.sql.query('SELECT * FROM todo_owners WHERE id = ?', [ ctx.principal, ])[0]; if (!owner) throw substratError( 'precondition_failed', 'no account here — join before creating a list', ); const id = ulid(); ctx.sql.exec( 'INSERT INTO todo_lists (id, owner_id, name, created_at) VALUES (?, ?, ?, ?)', [id, owner.id, input.name, ctx.now()], ); // The edge the permission walk follows. Without it the owner's grant on // their own entity would reach nothing. ctx.link(listRef(id), { entityType: 'owner', entityId: owner.id }); const row = listOrThrow(ctx, id); ctx.emit({ type: 'todo.list-created', schemaVersion: 1, entity: listRef(id), piiClass: 'none', payload: { id: row.id, name: row.name, owner_id: row.owner_id }, }); return row; }, /** * The proof walk. Every list is a candidate and each one is asked; a list * nobody shared simply never answers yes. * * Deliberately NOT a `WHERE owner_id = ?` — that would be a second, hand-kept * description of who may see what, and the one that gets forgotten. */ // #811. The walk is the kernel's; the per-row proof check is this vertical's, // and `pageVisible` is what keeps the two honest — it filters the batch and // advances the cursor by the last row EXAMINED, so a page of rows the caller // cannot see still moves the walk forward instead of stalling on it. 'todo/my-lists': async (ctx, input) => pageVisible( (p) => ctx.page('list', p), input, async (list) => (await ctx.check(TODO_PERM.listContribute, listRef(list.id))).allowed, ), 'todo/rename-list': async (ctx, input) => { assertAllowed(await ctx.check(TODO_PERM.listManage, listRef(input.listId))); listOrThrow(ctx, input.listId); ctx.sql.exec('UPDATE todo_lists SET name = ? WHERE id = ?', [input.name, input.listId]); const row = listOrThrow(ctx, input.listId); ctx.emit({ type: 'todo.list-renamed', schemaVersion: 1, entity: listRef(row.id), piiClass: 'none', payload: { id: row.id, name: row.name }, }); return row; }, 'todo/delete-list': async (ctx, input) => { assertAllowed(await ctx.check(TODO_PERM.listManage, listRef(input.listId))); listOrThrow(ctx, input.listId); ctx.sql.exec('DELETE FROM todo_items WHERE list_id = ?', [input.listId]); ctx.sql.exec('DELETE FROM todo_shares WHERE list_id = ?', [input.listId]); ctx.sql.exec('DELETE FROM todo_lists WHERE id = ?', [input.listId]); ctx.emit({ type: 'todo.list-deleted', schemaVersion: 1, entity: listRef(input.listId), piiClass: 'none', payload: { id: input.listId }, }); return { id: input.listId, deleted: true }; }, 'todo/list-items': async (ctx, input) => { assertAllowed(await ctx.check(TODO_PERM.listContribute, listRef(input.listId))); // Keyset, not offset: the cursor is the last row's id and the walk is exclusive, so // an item added mid-walk cannot push a row onto a page the caller already read. // `ORDER BY id` alone — a ULID is creation-ordered, so the old `created_at, id` // ordering is the same sequence with a second column the cursor could not name. const limit = input.limit ?? LIST_PAGE_DEFAULT; const rows = input.cursor ? ctx.sql.query( 'SELECT * FROM todo_items WHERE list_id = ? AND id > ? ORDER BY id LIMIT ?', [input.listId, input.cursor, limit], ) : ctx.sql.query('SELECT * FROM todo_items WHERE list_id = ? ORDER BY id LIMIT ?', [ input.listId, limit, ]); // Counted over the SAME filter the page ran under. Counting the table instead // would put a number beside the list that is wrong the moment there are two lists. const total = ctx.sql.query<{ n: number }>( 'SELECT COUNT(*) AS n FROM todo_items WHERE list_id = ?', [input.listId], )[0]!.n; return countedPageOf(rows, limit, (row) => row.id, total); }, /** * Search on one list (#827) — one check, then hydrate. * * The index is SCOPE-wide: `ctx.search` knows nothing about lists, so the hits * arrive from every list in the scope and the `list_id` in the WHERE is what * narrows them. That filter runs AFTER the ranking, which is why the index is * asked for more than the caller wants — a top-20 that loses 18 rows to the * filter would answer with two and call it the whole result. */ 'todo/search-list-items': async (ctx, input) => { assertAllowed(await ctx.check(TODO_PERM.listContribute, listRef(input.listId))); const limit = input.limit ?? DEFAULT_SEARCH_LIMIT; const fetch = overfetch(limit); const hits = ctx.search('item', input.q, { limit: fetch }); if (hits.length === 0) return { results: [], limit, capped: false }; const rows = ctx.sql.query( `SELECT * FROM todo_items WHERE list_id = ? AND id IN (${hits.map(() => '?').join(', ')})`, [input.listId, ...hits.map((h) => h.id)], ); // `IN (…)` returns rows in whatever order the table hands them back, so the // rank has to be put back deliberately: a search that lists the best match // third is a search people stop using. const ordered = rankOrder(hits, rows); return { results: ordered.slice(0, limit), limit, capped: ordered.length > limit || hits.length === fetch, }; }, /** * Search across every list the caller reaches — the same proof walk as * `my-lists`, run over search hits instead of over every row. * * `narrows` rather than a node-level check, because there is no node-level * answer: nobody holds `list:contribute` scope-wide, so "may you see this item" * is only ever a question about the list it sits on. Asked once per distinct * list rather than once per hit — forty matching items on one list is one * question, not forty. * * Stopping at `limit` is what makes this cheap: the walk ends as soon as the * answer is full, so a scope with many unreachable matches costs the checks it * takes to find `limit` reachable ones, not one per hit. */ 'todo/search-items': async (ctx, input) => { const limit = input.limit ?? DEFAULT_SEARCH_LIMIT; const fetch = overfetch(limit); const hits = ctx.search('item', input.q, { limit: fetch }); if (hits.length === 0) return { results: [], limit, capped: false }; const rows = ctx.sql.query( `SELECT * FROM todo_items WHERE id IN (${hits.map(() => '?').join(', ')})`, hits.map((h) => h.id), ); const byId = new Map(rows.map((row) => [row.id, row])); const reachable = new Map(); const results: ItemRow[] = []; for (const hit of hits) { const row = byId.get(hit.id); if (!row) continue; let allowed = reachable.get(row.list_id); if (allowed === undefined) { allowed = (await ctx.check(TODO_PERM.listContribute, listRef(row.list_id))).allowed; reachable.set(row.list_id, allowed); } if (!allowed) continue; results.push(row); if (results.length === limit) break; } // Full either because the walk stopped early or because the index itself // capped — both mean there may be more, and neither can be paged past. return { results, limit, capped: results.length === limit || hits.length === fetch }; }, 'todo/add-item': async (ctx, input) => { assertAllowed(await ctx.check(TODO_PERM.listContribute, listRef(input.listId))); listOrThrow(ctx, input.listId); const id = ulid(); ctx.sql.exec( 'INSERT INTO todo_items (id, list_id, text, done, added_by, created_at) VALUES (?, ?, ?, 0, ?, ?)', [id, input.listId, input.text, ctx.principal, ctx.now()], ); const row = ctx.sql.query('SELECT * FROM todo_items WHERE id = ?', [id])[0]!; ctx.emit({ type: 'todo.item-added', schemaVersion: 1, entity: { entityType: 'item', entityId: id }, piiClass: 'none', payload: { id: row.id, list_id: row.list_id, text: row.text, added_by: row.added_by }, }); return row; }, 'todo/set-item-done': async (ctx, input) => { const { list } = itemAndList(ctx, input.itemId); assertAllowed(await ctx.check(TODO_PERM.listContribute, listRef(list.id))); ctx.sql.exec('UPDATE todo_items SET done = ? WHERE id = ?', [input.done ? 1 : 0, input.itemId]); const row = ctx.sql.query('SELECT * FROM todo_items WHERE id = ?', [input.itemId])[0]!; ctx.emit({ type: 'todo.item-done-changed', schemaVersion: 1, entity: { entityType: 'item', entityId: row.id }, piiClass: 'none', payload: { id: row.id, list_id: row.list_id, done: row.done }, }); return row; }, /** Deleting is the owner's, which is what `list:manage` means here. */ 'todo/delete-item': async (ctx, input) => { const { list } = itemAndList(ctx, input.itemId); assertAllowed(await ctx.check(TODO_PERM.listManage, listRef(list.id))); ctx.sql.exec('DELETE FROM todo_items WHERE id = ?', [input.itemId]); ctx.emit({ type: 'todo.item-deleted', schemaVersion: 1, entity: { entityType: 'item', entityId: input.itemId }, piiClass: 'none', payload: { id: input.itemId }, }); return { id: input.itemId, deleted: true }; }, /** * Sharing — the whole point of the app, and one line of access control. * * `ctx.grant` narrows `list:contribute` onto THIS list for THIS person. It * delegates: the kernel re-checks that the caller holds it there, so an * operation can never hand out more than it was given. Nothing else in the * app has to remember that Björn may touch this list — the grant is the fact, * and every check reads it. */ 'todo/share-list': async (ctx, input) => { assertAllowed(await ctx.check(TODO_PERM.listManage, listRef(input.listId))); listOrThrow(ctx, input.listId); const invitee = ctx.sql.query('SELECT * FROM todo_owners WHERE email = ?', [ input.email, ])[0]; if (!invitee) throw substratError('not_found', `nobody here with that address: ${input.email}`); const existing = ctx.sql.query( 'SELECT * FROM todo_shares WHERE list_id = ? AND principal = ?', [input.listId, invitee.id], )[0]; if (existing) return existing; const id = ulid(); ctx.sql.exec( 'INSERT INTO todo_shares (id, list_id, principal, email, created_at) VALUES (?, ?, ?, ?, ?)', [id, input.listId, invitee.id, invitee.email, ctx.now()], ); await ctx.grant(invitee.id as PrincipalId, TODO_PERM.listContribute, listRef(input.listId)); const row = ctx.sql.query('SELECT * FROM todo_shares WHERE id = ?', [id])[0]!; ctx.emit({ type: 'todo.list-shared', schemaVersion: 1, entity: { entityType: 'share', entityId: row.id }, piiClass: 'pseudonymous', subjectId: dataSubjectId.parse(row.principal), // The address cannot ride along: `share.email` is erasable, and an // immutable event is the one place an erasure cannot reach. payload: { id: row.id, list_id: row.list_id, principal: row.principal }, }); return row; }, 'todo/list-shares': async (ctx, input) => { assertAllowed(await ctx.check(TODO_PERM.listManage, listRef(input.listId))); // The kernel composes it: `ORDER BY created_at, id` is the declared sort plus // its tie-break, which is the ordering this shipped with, written once. return ctx.page('share', { ...input, filters: { list_id: input.listId } }); }, 'todo/revoke-share': async (ctx, input) => { const share = ctx.sql.query('SELECT * FROM todo_shares WHERE id = ?', [ input.shareId, ])[0]; if (!share) throw substratError('not_found', `share not found: ${input.shareId}`); assertAllowed(await ctx.check(TODO_PERM.listManage, listRef(share.list_id))); ctx.sql.exec('DELETE FROM todo_shares WHERE id = ?', [input.shareId]); await ctx.revoke(share.principal as PrincipalId, TODO_PERM.listContribute, listRef(share.list_id)); ctx.emit({ type: 'todo.share-revoked', schemaVersion: 1, entity: { entityType: 'share', entityId: share.id }, piiClass: 'none', payload: { id: share.id }, }); return { id: share.id, revoked: true }; }, } satisfies { // Derived by the platform, not restated here — `HandlerOutput` is what knows that a // `paged` declaration means the handler returns a Page of the declared entry. [K in keyof typeof todoOperations]: OperationHandler< HandlerInput<(typeof todoOperations)[K]>, HandlerOutput<(typeof todoOperations)[K]> >; }; export const todoModule: ModuleRegistration = { manifest: todoManifest, migrations: todoMigrations, // The host parses every invocation against the same declaration the routes and // the document come from, so "parse, don't trust" holds on every path in — HTTP, // test, seed — rather than in the handlers that remembered (#953). operationInputs: operationInputsOf(todoOperations), operations: operations as ModuleRegistration['operations'], }; ``` ## What this shows, and what it does not **What it does not show.** Todo composes **no engine**. Its `defineOperations` takes no engine argument, and that is a fact about the app rather than an omission: nothing here has an invariant beyond "you cannot touch a list nobody shared with you", and that one is the platform's own. So this page shows the model → code pipeline and not engine composition, which is a large part of what a real vertical does. For that, read [Callout](https://substrat.net/verticals/callout.md) — a work order that composes the [workorder](https://substrat.net/engines/workorder/index.md) and [protocol](https://substrat.net/engines/protocol/index.md) engines inside its own transaction. **What it does show.** Here is the smallest app anyone could write, and it is already multi-tenant, permission-checked on every operation, and audited on every change — and none of that appears in the code above. "A todo app makes the platform look like overkill" is true only if you show the app. Show what you did *not* write and it is the point. ## The honest comparison: Wasp Wasp's `TodoAppTs` is the same app, and it is worth putting next to this one because Wasp is good and the difference is real. **On line count, Wasp wins.** `main.wasp.ts` is about 30 lines, `schema.prisma` a dozen, `actions.ts` 34, `queries.ts` 13 — under a hundred lines to Substrat's roughly 800 across `model.ts` and `module.ts`. Their terseness is genuine, and this page is not structured as though it were not. A good part of the difference is that Substrat's two files carry the things Wasp leaves to convention: which permission each operation checks, what each mutation emits, what is personal data, how a list is paged. **The axis where Substrat is different is what a handler looks like.** Wasp's update action is this: ```ts return context.entities.Task.updateMany({ where: { id: args.id, user: { id: context.user.id } }, // forget this and anyone edits anything data: { isDone: args.isDone }, }); ``` The ownership clause is the whole access control, it is restated in every handler, and nothing in the framework notices if one of them drops it — the types stay perfect. Their schema even has `userId Int?`, so a task can exist with no owner at all. In todo the same edge is `parents: ['list']` in the model plus one `ctx.check(perm, entityRef)` at the top of each handler, declared once rather than remembered per handler — and the declaration is what the conformance kit drives, so a handler that checked the node instead of the entity goes red in CI rather than shipping. That is not a claim that Substrat's code is shorter. It is a claim about where the mistake can live. --- # Agent rules Source: https://substrat.net/guide/agent-rules.md **If you are an agent building on Substrat, this is the page to read first.** It is the always-on contract — the rules that hold no matter what you touch — and most of what a generated vertical gets wrong is on this page. This is not a summary written for the website. It is the exact file [`create-substrat`](https://substrat.net/reference/create-substrat.md) writes into every scaffolded project as `AGENTS.md`, published here so an agent that has not scaffolded anything can still read it. The two are kept identical mechanically; a rule cannot say one thing in your project and another thing here. What this page is *not* is the build flow. Interviewing for a domain, mapping it onto the engines, and landing a design document a human approves before any code is a **playbook**, invoked when you start a vertical — `/substrat` in Claude Code, the `new-vertical` command in Cursor and opencode. Scaffold first with [Getting started](https://substrat.net/guide/getting-started.md); this page is what a session already mid-build must never violate. ## The mental model Three layers. You only own the third. 1. **Kernel — free, always.** Tenancy (one scope = one isolated database; there is no cross-tenant API), permissions (roles, grants, and a proof path for every decision), events + audit (every mutation emits a kernel-stamped event you cannot mislabel), migrations (journaled per module, applied lazily per scope). 2. **Engines — compose or feed.** Headless, own invariants that cannot be violated (state machines that can't skip states, append-only entries). You either **compose** an engine (import it; its in-scope functions run in *your* transaction) or **feed** it (emit a fat event; it consumes — no import). Engines never import each other. Read an engine's real surface from `node_modules/@substrat-run/engine-*/dist/index.d.ts` — never guess at it. 3. **Your vertical — everything a user touches.** Vocabulary, price list, extra fields, roles, screens. If your core noun isn't something an engine already owns, this is most of the app — a normal, supported outcome. ## Project layout The linter and tests expect this shape. `manifest`/`migrations`/`module` are **module code** (the rules below bind them); `seed`/`server` are **harness** (exempt). ``` src/entities.ts defineEntities — WHAT EXISTS ← module code src/operations.ts defineOperations — the declared surface ← module code src/manifest.ts moduleManifest.parse({…}) + PERM consts ← module code src/migrations.ts the SqlMigration[] ← module code src/module.ts the handlers, bound to the declaration ← module code src/provision.ts MODULES, ROLES, grant shapes — node-free ← module code src/seed.ts host, tenants, demo cast, seed world ← harness src/personas.ts the dev cast, read by the issuer and the seed ← harness src/routes.ts the routes, DERIVED from the operations ← harness src/server.ts the dev entrypoint (node + persona picker) ← harness src/worker.ts the deployable Cloudflare worker ← harness src/config-do.ts per-instance config store (Cloudflare only) ← harness test/scenario.test.ts the scenario — including the denials test/entities.test.ts the registry, held to the tables it migrates ``` **A new route is an `http` declaration on its operation, never a handler in an entrypoint.** `src/routes.ts` holds no table: `mountOperations` (from `@substrat-run/vertical-host`) derives one from the `http` each operation in `src/operations.ts` declares — method, path, and which input fields the path carries, compile-checked there — and both `server.ts` and `worker.ts` mount that one derivation, so a route is live on both the moment it is declared. A route added to only one entrypoint is a surface that works in dev and 404s in production (or the reverse), which nothing catches until you deploy: the scenario tests call operations directly and never boot either host. A composed engine's operations carry no `http` of their own (an engine does not own a URL shape), so the vertical binds them with `defineEngineRoutes` beside its own declarations. What an entrypoint may still own is only what is genuinely its own — building a host, resolving a caller, and its own auth-shaped routes (`/api/auth/*` and `/api/me` in dev, `/api/me` in the worker). `provision.ts` is deliberately node-free: both hosts register from it (the dev server's SQLite host and the worker's `ScopeDO`), and `substrat push` reads the permission registry from it (package.json `substrat.permissions`). Roles or modules defined anywhere else will run locally and silently not deploy. `worker.ts` **mounts** the platform's `/internal/*` management contract via `mountPlatformSurface` from `@substrat-run/vertical-host` (one call — the routes and the `{ error }` envelope are authored there, not here, so they can't drift or ship half-done). What `worker.ts` still owns is **the auth seam** — it resolves nobody until you wire real auth there, and every `/api/*` call is 401 until you do. That is deliberate: there is no dev header to forget to turn off. `src/server.ts` shows the shape, signing in against the local OIDC issuer `pnpm dev` starts. **A UI ships as declared assets.** If `app/` exists, `substrat.runtimeNeeds.assets` must point at its build output and `runtimeNeeds.build` must produce it — otherwise the deployed vertical serves the API and 404s on `/`, with every local gate green. Declare it in the same change that creates `app/`, not at deploy time. (`substrat push` refuses an undeclared UI, but by then you are already deploying.) Among the hooks it passes, **`onConfigure` is the one you must not drop.** It is how per-instance settings reach the running app: the dashboard's Settings → Env and Identity tabs POST to `/internal/configure`, and a vertical that supplies no hook answers **501** to that call for its whole life — the setting is saved, the dashboard reports `delivered: false`, and the app never sees it. That includes the `substrat:auth` issuer choice, i.e. the difference between a working login and 401-on-everything. The starter stores deliveries in `config-do.ts` and reads them back through `resolveScopedEnvSpec` (`instanceConfig`). Read settings that way and never off `env` directly: an `envSpec` default rides as a worker binding shared by every install of one serving script, so `env.FOO` is the same string for every tenant no matter what any of them saved. Declare a setting once, in `src/manifest.ts` (`SHOP_ENV`) — `src/provision.ts` re-exports it as `envSpec`, which is what `substrat push` uploads; package.json carries no copy. ## Declare what exists, then implement it A vertical declares its **entities** and its **operations** in two typed modules, and the compiler checks the joins between them. This is not documentation of the code — it is the code's other half, and the reason a whole class of mistake stops being something a reviewer has to catch. `src/entities.ts` says what exists. Each entry names the table, the row shape as `ctx.sql` returns it (snake_case included — a prettier second naming is exactly the second description this removes), its natural `key`, its `parents` (the permission walk), and which fields are `erasable`: ```ts export const bikeShopEntities = defineEntities({ customer: { table: 'shop_customers', fields: z.object({ id: z.string(), number: z.string(), name: z.string(), … }), key: ['number'], erasable: ['name', 'phone'], }, bike: { table: 'shop_bikes', fields: …, parents: ['customer'] }, }); ``` Not every table is an entity. An entity is a thing the platform can point AT — attachments hang off one, grants narrow to one, events are about one. `shop_price_list` is keyed by article, has no id and is never the subject of an `EntityRef`, so it is deliberately absent and its shape lives beside the operations that return it. `src/operations.ts` says what each operation accepts, answers with, and is gated by — against those entities and a declared list of permission keys: ```ts export const bikeShopOperations = defineOperations(bikeShopEntities, SHOP_PERMISSIONS)({ 'shop/create-customer': { summary: 'Register a workshop customer', permission: 'customer:manage', input: z.object({ number: z.string().min(1), name: z.string().min(1) }), output: bikeShopEntities.customer.fields, // from the registry, not restated }, }); ``` Then `src/module.ts` holds only the **bodies**, bound to that declaration: ```ts } satisfies OperationImpl; ``` Four things are now compile errors at the exact method: a handler whose input disagrees with the declared `input`, one whose return disagrees with `output`, an operation declared and not implemented, and one implemented and not declared. The same object feeds `operationInputs: operationInputsOf(bikeShopOperations)`, so the schemas the host parses with are the ones the declaration states — they cannot drift, because there is only one of them. The permission list works the same way. `SHOP_PERMISSIONS`, the array handed to `defineOperations`, is also the `keys` that `src/provision.ts` hands `definePermissions({ modules, roles, entityGrants, keys })` — and `definePermissions` throws at module load if those keys and `MODULES` disagree in either direction. Hand both readers the same array; a second copy is a list that drifts. **A list read must declare `paged`.** `defineOperations` refuses a bare-array `output` that does not, and it refuses it at module load — so it fires in every build, every test and every dev server rather than in a lint tool that has to find you. A list endpoint returning the whole table is a bug with a delay on it: it passes review, it passes tests, and then one tenant's table gets large. Declare the **entry** as `output` and let `paged` wrap it; the handler returns `Page`, which `pageOf` builds. Either the kernel composes the walk (`paged: { over: { entity: 'customer', sortable: ['number'] } }`) or, where it cannot — a kernel table, a per-row permission walk, a table the registry does not carry — the handler composes its own and names the field the cursor walks (`paged: { sortKey: 'article' }`). Keyset, never offset: on live data an offset shifts between requests, so pages drop and duplicate rows. **A paged read has an HTTP half, and the derived route does it for you.** The operation answers with a `Page` — it is transport-agnostic, and a test or a seed must be able to walk a list with no response to read headers off — so the projection happens at the edge: `mountOperations` forwards the page trio in (`limit`, `cursor`, `order`, parsed with the platform's own defaults and ceiling) and hands the entries back as the body with the walk in a `Link` header. Declare `http` on the paged operation and both halves are there; hand-mount a paged read yourself and you have to do both by hand — forget the first and the endpoint is pinned to page one no matter what the operation supports, forget the second and it answers with an envelope where it used to answer with an array. That is the case for not hand-mounting anything an operation can declare. ## The rules (non-negotiable) **Module code** = everything reachable from a `ModuleRegistration` (operations, consumers). Rules 1–5 are enforced mechanically by `boundary-lint`. 1. **Data access is `ctx.sql` only.** Never import `better-sqlite3`, an adapter, `node:*`, or `cloudflare:workers` in module code. That last one is not a style rule: it exports an ambient `env`, so a single import hands module code every binding and secret your worker declares — including its own `SCOPE` Durable Object namespace, which reaches *another scope's* data. `ctx.sql` is closed over one scope and cannot. Capabilities arrive on `ctx`; `DurableObject` is imported in harness code (`worker.ts`, `*-do.ts`), never here. 2. **No `fetch` / network in module code.** It would hold the scope's transaction open on a third party. The sanctioned path is a **connector**: emit a fat event, register a handler that runs outside the transaction. An integration is never impossible because of this rule — it has an answer. 3. **Never write `_substrat_*` tables.** Reads are fine (timelines are projections); writes forge the audit spine. Do the read with `readTimeline` / `readHistory` from `@substrat-run/kernel` rather than a `SELECT` of your own: both take an `EntityRef`, page like a list read, and decode the envelope for you. `readHistory` also returns the payload, the authorization chain (which checks the operation passed, and under which grant), the impersonation stamp, the PII class, the `operation` the event was emitted from (the exact `invoke()` string) and the `version` the emitting code was deployed as. On all but the PII class, a `null` is a *fact* rather than data you failed to fetch — the payload was erased, the row predates authorization recording, nobody was impersonating, a consumer emitted it (or the row predates the column), no version identity was present — so render it as that. `version` comes from the outbox column only, never the envelope, so this helper is the one way to join an event to the push that wrote it. Neither helper checks a permission; you do, before you call it. 4. **Another module's tables are private.** Never `SELECT` from `workorder_*` etc. — use the engine's exported in-scope functions. This is the rule with no runtime equivalent: the shortcut *works* and silently welds you to an engine's private schema forever. Need extra data on an engine entity? Add **your own side table keyed by the engine's id** — never a column upstream. 5. **Time comes from `ctx.now()`.** Module code has no other clock — `new Date()` and `Date.now()` are banned exactly like `node:*`. It is the same instant for the whole operation, so your rows and the events announcing them agree about when. Store it as ISO text, never an epoch integer. Because the host injects the clock, a scenario can test elapsed time (`manualClock` from `@substrat-run/kernel`) instead of sleeping or shrinking the window to zero — the workaround that proves nothing. 6. **Every operation checks a permission first.** `assertAllowed(await ctx.check(PERM))` is the first line. 7. **Every mutation emits a fat event** — a consumer must never need a cross-module read. 8. **Never fork an engine.** Extend by composition. If you must fork, the engine drew its line wrong — that's design feedback, not a coding problem. 9. **IDs are `ulid()`. Money is strings** via `@substrat-run/contracts` helpers (`moneyOf`, `mulMoney`, `addDecimal`, `compareDecimal`) — never floats. 10. **Web-standard APIs always** — `globalThis.crypto`, `TextEncoder`, `URL`. Never hand-roll a hash to dodge an import ban. 11. **Parse, don't trust** — and the **host** is what parses. A module passes `operationInputs: operationInputsOf(ops)` beside its `operations`, and every invocation is parsed against the declared schema before the guards and the handler, on every path in (HTTP, test, seed, schedule). Handlers do not hand-parse; a declared input nobody parses stops being possible rather than merely discouraged. Import `z` from `@substrat-run/contracts`, **never from `zod`**. Zod schemas don't compose across copies or majors; composing a contracts schema into one built from a separate `zod` fails at *runtime* (`expected a Zod schema`) with an error pointing nowhere near the cause. ## Swallowing an engine error requires `ctx.atomic` An engine call composed inside your transaction has no boundary of its own. A `catch` that handles the failure and carries on therefore leaves you holding the engine's partial writes — the rows its invariants were protecting — and then commits them. Give the call a boundary instead: ```ts try { await ctx.atomic(() => completeWorkOrder(ctx, { orderId, billable })); } catch { // the engine's rows, events, links and grants are all gone; your own writes // survive, and the operation still commits once } ``` A succeeded `ctx.atomic` is still provisional: if the operation later throws, its writes go too. Sub-transactions nest but must not interleave — starting two concurrently throws. The line is whether the failure still reaches the caller. A catch that **swallows** an engine error — one that does not rethrow — is what needs the boundary, and outside `ctx.atomic` `boundary-lint` rejects it with **no** escape hatch. A catch that always rethrows (`catch (e) { log(e); throw e }`) needs nothing, and neither does `try`/`finally` with no `catch`: the operation still fails and the whole transaction rolls back, which is already the outcome the rule protects. `ctx.atomic` is what you reach for when you intend to *continue* past the failure. "Always rethrows" is read literally — the catch's last statement is the `throw`. A `throw` buried in an `if` block is not that, because the catch runs on past it. ## Declare every link edge `entityRelations` in the manifest must declare every edge you traverse — both your own (`bike → customer`) and the ones an engine makes on your behalf (`workorder → bike`). The adapter **rejects** a `ctx.link` for an undeclared edge, so a missing one fails loudly. This is also what lets a portal permission-walk reach the owner. ## Sharing is `ctx.grant` / `ctx.revoke`, not a table When a person shares their own record with another person — and takes them off it again — the operation narrows a permission it already holds onto that one entity: ```ts await ctx.grant(principal, PERM.listContribute, listRef(listId)); // share await ctx.revoke(principal, PERM.listContribute, listRef(listId)); // un-share ``` Entity-required (module code can never write a scope- or tenant-wide grant), delegating (the caller's own decision on that entity is re-checked, so an operation can never hand out more than it holds), and transactional with the operation. Every later `ctx.check` reads the grant, so nothing else has to remember who may touch what. Neither alternative is this, so you can tell a real absence from this one: a `ctx.link` edge is **not revocable at all** — it is permanent — and org membership is revocable but coarse-grained — a whole org, not one record. Never mint an org per domain row, or a membership table consulted by hand in every handler, to get a revoke. The two-line reference is the [todo demo](https://github.com/substrat-run/substrat/tree/main/demos/todo) (`src/module.ts`, `todo/share-list` and `todo/revoke-share`). ## The gates — run them, believe them ```sh npm test # the scenario, including the denials npx @substrat-run/boundary-lint # the layer rules (1–5) npm run typecheck ``` `boundary-lint` exits non-zero if it *couldn't do its job* (no module code found, no engines resolvable) — a pass that checked nothing is worse than no linter. Never wave that through; fix the setup until it can see your code. A green scenario test does **not** mean the app works: the test calls operations directly and never exercises `server.ts`, its routes, or the principal picker. Before calling a vertical done, boot the server and drive the real flow over HTTP as two personas — one who should succeed and one who should be denied — and confirm the denial arrives as a denial (not a generic error). `.claude/launch.json` is what starts those servers for an agent with a browser pane, and it declares a **port** — never a path or a query. So the pane opens each server's bare origin, and no entry can deep-link a page underneath it: reaching one is a navigation you perform once the preview is up, not something configuration expresses. Do that without being asked. The page worth opening first on a vertical that serves the Scalar API reference is **`/api/docs`** — every operation, rendered from that vertical's own `/openapi.json`, on the vertical's own origin, so a try-it request carries the session cookie you already have and a 403 in the playground is the permission system working rather than a broken page. ## When it breaks — symptom → fix Six failures that have each cost someone a day. What makes them expensive is that none of them names its own cause: the symptom points somewhere other than the fix, so an agent debugging from first principles walks away from the answer. | Symptom | Fix | |---|---| | `expected a Zod schema` at runtime, pointing nowhere useful | Two copies of Zod. Never add `zod` to `package.json` — import `z` from `@substrat-run/contracts`, so the schema you build and the one the host validates with are the same class. | | Killing the dev server kills unrelated ones too | `pkill -f 'tsx src/server.ts'` matches every Substrat project running on the machine, not just yours. Kill by port instead: `kill $(lsof -ti :)`. | | Green locally, red in CI, with nothing in the diff that explains it | A warm build output hides it. Delete `dist`, reinstall from the lockfile (`--frozen-lockfile`), and re-run the gates before believing a local green. | | Green test suite, broken app | The scenario calls operations directly and never reaches `server.ts`, its routes, or the principal picker. Boot the server and drive the flow over HTTP as two personas — one who should succeed and one who should be denied. | | Permission denied after you widened a role | Roles are projected into a scope when that scope is provisioned, from `ROLES` in `src/provision.ts`. An existing scope keeps the projection it was born with — re-provision it (or re-seed onto a fresh data directory), then present the permission diff below. | | `pnpm install` dies compiling `better-sqlite3` | Take `better-sqlite3` out of `pnpm.onlyBuiltDependencies`. Since 13.x it ships prebuilt binaries and no install script, but it still ships a `binding.gyp` — so an allowlist entry makes pnpm run a `node-gyp rebuild` that nothing here needs. | ## Two human checkpoints — you may never self-approve Present these and stop: 1. **Migration diff** — every new `SqlMigration`, verbatim. Migrations are append-only forever once shipped, so this is the last cheap moment to change your mind. 2. **Permission diff** — a table: key → description → which roles hold it → why. Walk the reviewer through it in their own vocabulary until they can answer *who can now see the money, and who can see other tenants' data?* A permission diff nobody understands is theater — it reproduces the exact failure Substrat exists to prevent. --- # The Claude Code plugin Source: https://substrat.net/guide/agent-plugin.md A scaffolded Substrat project already teaches an agent what it needs: `npm create substrat` writes the rules, the build playbook, and a session hook that pins the agent to the docs for the kernel version installed there. The plugin exists for the case before that — an agent opened on a directory we did not generate, which has none of it. ```sh claude plugin marketplace add https://substrat.net/marketplace.json claude plugin install substrat@substrat-run ``` In Claude Desktop, install it from **+** → **Plugins** → **Add plugin**. Plugins are not available in WSL sessions. The marketplace also resolves from the repository, if you prefer that form: ```sh claude plugin marketplace add substrat-run/substrat ``` Both register the same catalog. The URL form downloads one small file; the repository form clones the monorepo, so prefer the URL unless you have a reason not to. ## What you get **`/substrat`** — the guided build flow. It works out which of three situations you are in and routes accordingly: an existing Substrat vertical (read its rules and playbook), an empty directory (scaffold one first, then the same), or an existing project that is not a vertical (say so, rather than improvise — Substrat is not a library you add to an app). **A `SessionStart` hook** — silent in any project that is not a Substrat vertical, so it costs you nothing elsewhere. In one that is, it tells the agent where the rules live and which docs slice describes the kernel actually installed. Substrat is pre-1.0 and its interfaces change without notice, so an agent working from pages it cached two minors ago is the expensive failure: confident, plausible, wrong. ## What it deliberately does not contain The build flow itself. That lives in the project, in `.substrat/playbook.md`, beside the [agent rules](./agent-rules.md) in `AGENTS.md` — and that copy is pinned to the kernel version installed there. A copy inside the plugin would go stale on its own schedule, and the rules an agent must not violate are the worst possible thing to be stale about. The plugin routes to the project's copy and never substitutes for it. This is why the plugin adds nothing to a project you scaffolded: that project already carries its own copy of the skill and the hook. Installing it anyway is harmless — the project's copies win — but it is not something you need to do. ## Other tools The plugin is a Claude Code adapter. Every other client reads the same neutral core, which the scaffold writes into the project directly: | Tool | How it finds the rules | | --- | --- | | Claude Code | `CLAUDE.md`, `.claude/skills/`, `.claude/settings.json`, `.claude/launch.json` | | Cursor | `.cursor/rules/substrat.mdc`, `.cursor/commands/new-vertical.md` | | opencode | `.opencode/command/new-vertical.md` | | Codex, Kiro | `AGENTS.md`, natively — nothing to install | For an agent that has not scaffolded anything and is reading rather than building, [`substrat.net/llms.txt`](https://substrat.net/llms.txt) is the docs index and every page on this site has a `.md` twin at the same URL. --- # Building for AI agents Source: https://substrat.net/guide/ai-agents.md Substrat treats coding agents as primary users of the platform, and that shapes the API more than any other single requirement. If you're pointing Claude Code (or any agent) at a Substrat vertical, this page explains what the platform does to make itself **legible** to one — the surface, the docs, and the rules a project announces about itself. ## The one-paragraph version The layer where LLMs are weakest — tenancy, auth, migrations, integrations, compliance — is the layer where mistakes are catastrophic. The layer where they are strongest — screens, forms, workflows, reports — is the layer where mistakes are cosmetic. Substrat puts hard guarantees under that line and agent velocity above it. ## What stops a mistake That half of the story — the six guards a change passes through, the layer rules, the oracle the build is not allowed to edit, and the two things an agent never self-approves — has its own page: **[Where AI mistakes stop](https://substrat.net/guide/ai-guardrails.md)**. Read it first if the question you have is *"what happens when the agent gets it wrong"*. What follows here is the other half: what makes the platform readable to an agent in the first place. ## Bring your own model, bring your own agent You are not locked into someone's prompt box. Design and build run in *your* agent against repo skills — `npm create substrat` writes `AGENTS.md`, `.substrat/playbook.md`, and command stubs for Claude Code, Cursor and opencode alike, so the project announces its own rules and build flow to whatever you open it in. And the code is on your disk, running locally: not "export a zip that dies without our SDK", but a repo that boots against SQLite with no platform in the loop. ## These docs, as markdown An agent that reads this site as HTML spends its context on navigation and theme markup. Every page here is also published as raw markdown at the same path plus `.md` — `/concepts/model` is also [`/concepts/model.md`](https://substrat.net/concepts/model.md) — and two files index the whole set: | File | What it is | |---|---| | [`/llms.txt`](https://substrat.net/llms.txt) | The index: every page, grouped by section, one line of description each. Start here and fetch only the pages you need. | | [`/llms-full.txt`](https://substrat.net/llms-full.txt) | Every page concatenated, for one-shot ingestion. Large — the index exists so this is the fallback, not the default. | Both state the `@substrat-run/kernel` version they describe, and `/llms.txt` is also served at `/llms-.txt`. That second URL is the one to pin: Substrat is 0.x and interfaces change without notice, so an agent holding pages from two minors ago is a real failure mode. Fetching `/llms-.txt` returns 200 only while the published docs still describe your kernel — a 404 means they have moved on, and the answer is to re-read `/llms.txt` rather than trust the cache. > **Tip — Fetch the markdown, not the page** > > Point your agent at the `.md` URLs. Links *inside* those files already point at other `.md` > files, so an agent that follows a reference stays in markdown instead of bouncing back into > HTML. ## The project announces itself You should not have to remember any of the above. A scaffolded vertical carries `.substrat/hooks/session-start.mjs`, which runs when an agent session opens and hands it three things it would otherwise have to discover: that this is a Substrat vertical, where the rules and the build flow live, and the URL of the docs slice describing **the kernel this project actually has installed** — read from `node_modules`, not from the range in `package.json`, because a caret on `0.x` pins the minor and the resolved version is what your code compiles against. It also remembers which version it last announced, so the first session after an upgrade opens by stating the jump rather than letting an agent discover it by being wrong. The script sits in `.substrat/` — the tool-neutral home, beside `playbook.md` — and `.claude/settings.json` is a three-line adapter that runs it. Any other client that grows a session hook binds to the same script. It is silent unless `package.json` has a `substrat` block, so it is inert in any other project; it makes no network request, so it costs nothing at startup and works offline; and it never fails a session — any unexpected error exits quietly. Opt out entirely by creating `.substrat/no-session-context`. > **Tip — Why not just check whether the docs match?** > > Because the check is already mechanical for whoever actually fetches. `/llms-.txt` > returns 200 only while the published docs still describe that kernel, so a 404 *is* the > mismatch warning — and putting an HTTP request on the critical path of every session start, > to compute an answer the next fetch computes for free, is a poor trade. ## Self-describing modules The [module manifest](https://substrat.net/concepts/modules.md) is what makes a Substrat system legible to an agent without reading its implementation: every module declares its permissions (with descriptions), the events it emits and consumes (with schema versions), its entity relations, its migrations and compatibility window, and its UI contributions. An agent scaffolding a new vertical can discover the whole surface of the installed engines from their manifests. ## Local loop The pure-SQLite adapter is the agent's development loop: real serialization semantics, real isolation, real stamped events, in-process, deterministic, fast. An agent can build a module, run the contract tests, inspect the resulting `.sqlite` files, and iterate — with no credentials and no shared environment to damage. ## Where this is honest about itself Nothing above claims an agent cannot write a bug. What the platform does about that — and what it still doesn't — is on [Where AI mistakes stop](https://substrat.net/guide/ai-guardrails.md#where-this-is-honest-about-itself) and [What Substrat doesn't have (yet)](https://substrat.net/guide/what-substrat-lacks.md). --- # Where AI mistakes stop Source: https://substrat.net/guide/ai-guardrails.md Nothing on this page claims an agent cannot write a bug. It claims that one **class** of bug — the catastrophic, silent, cross-tenant kind — is unreachable rather than discouraged, and that a second class — the small, confident, inconsistent kind — has something independent watching for it. This is the enforcement half of building on Substrat with an agent. The ergonomic half — bring-your-own-model, the markdown docs slice, the manifests an agent reads — is on [Building for AI agents](https://substrat.net/guide/ai-agents.md). ## The structural insight **Diagram — the line.** Two bands either side of one boundary. - **What an agent writes** — mistakes are cosmetic: screens, forms, workflows, reports, pricing, vocabulary. - **the line.** Above: AI velocity — mistakes are cosmetic (a wrong screen). Below: humans + runtime guarantees — mistakes are catastrophic (a tenant leak). - **What the substrate owns** — mistakes are catastrophic: tenancy, auth, migrations, integrations, audit, compliance. The layer where models are weakest is the layer where mistakes are fatal. So the split is structural rather than instructional: prompting a model to "be careful with tenancy" is a suggestion, and a `getScope` call that fails closed on a mismatched pair is a fact. Analyses of AI-generated apps keep finding the same list: missing row-level security, broken auth boundaries, no tenant isolation, no audit trail. Prompting a model to "be secure" measurably does not fix it, which is why the split above is architectural rather than instructional. The long form of that argument is [Why runtime enforcement?](https://substrat.net/guide/why-substrat.md). > **Tip — Defaults, not configuration** > > The subtler failure than *no enforcement* is enforcement you can misconfigure. There is no > API that returns another scope's data with the wrong flag set — the API for reaching a > scope *is* the isolation mechanism. There is no "remember to log this" — the event envelope > is stamped kernel-side on every `emit`, and tenant, scope, actor and time are not > parameters. The permission checker's secure default is deny; the permissive one is exported > as `UNSAFE_allowAllChecker`, where the name is the warning. ## Six guards, in the order they fire These are not layers of a diagram. They are stages a commit passes through in time, each one able to stop it, and the point of the sequence is that a mistake surviving one guard meets the next. **Diagram — the path.** Six guards, in the order they fire. Each stage can stop a change, and the claim is that a mistake surviving one meets the next. 1. **Compile — Invalid states don't typecheck.** A narrow, aggressively typed SDK means a small hallucination surface. A `ScopeId` will not typecheck where a `TenantId` is expected, so an agent cannot swap them silently. A permission `Decision` is either `{ allowed: true, proof }` or `{ allowed: false, checked, node }` — an unexplained allow is unrepresentable. An event without a `piiClass` doesn't parse, and a PII-classed event without a `subjectId` doesn't either. - *Stops:* ID confusion, silent allows, unclassified personal data. 2. **Bound — The shortcut is told apart from the sanctioned path.** Most guardrails fail loud. The layer rules are the ones that fail *silently* — a raw `SELECT` against an engine's private table returns the right rows, the test passes, and nothing tells you that engine can now never ship a migration. `boundary-lint` is the only thing that can see it. - *Stops:* Raw DB access, cross-module reads, forged audit rows, ambient secrets. 3. **Derive — What can be derived is never generated.** Wherever an artifact can be *derived*, it is derived by code, not by a model. Entities are declared once; the DDL, the API surface, the browser client and the permission table are emitted from that declaration, and CI re-emits each and fails on drift. Code generated by code beats code generated by a model on tokens, latency and exactness at once. - *Stops:* Hand-edits to shipped SQL, drifted clients, a widened role merging unseen. 4. **Judge — The build may not edit its own oracle.** Models fail quietly and inconsistently rather than loudly, and you cannot prompt that away — so the architecture has to catch it. Every defect worth catching is two descriptions disagreeing. Once the code is derived from the model it can no longer contradict it, so the second description has to come from somewhere else entirely. - *Stops:* A suite that ratifies a wrong model perfectly and forever. 5. **Review — Two things an agent never self-approves.** Schema migrations and permission definitions stay under human review even in a fully agent-driven shop. Both are read as a diff, and CI re-emits the artifact and fails on drift — which is what makes the reading unskippable. The red build is not the approval; it is what stops the approval being skipped. - *Stops:* A destructive migration or a quietly widened role reaching main. 6. **Rehearse — A running copy of production to be wrong in.** Reviewing a migration diff is a checkpoint. Watching that migration run against a copy of the real data is what makes the checkpoint honest. Crossing a migration-digest boundary snapshots the prior state first — the digest comparison is the gate, not a flag someone remembers to pass. - *Stops:* Version 2 landing on version 1's live data. Guard 01 is the figure above and needs no more room. The rest of this page is the five that do. ## Guard 02: the layer rules Every other guardrail in Substrat fails **loud**: branded IDs at compile time, Zod at the boundary, `getScope` closed on a mismatched `(tenant, scope)`. The layer rules are the ones that fail **silently**, which is why they need a linter rather than a convention. **Module code** is everything reachable from a `ModuleRegistration` — operations and consumers. Composition roots (`server.ts`, `seed.ts`, `worker.ts`) are harness and exempt. | Rule | What it enforces | |---|---| | **R1** star topology | an engine never imports another `@substrat-run/engine-*` | | **R2** no raw access | no `better-sqlite3`, no adapters, no `node:*`, no `cloudflare:workers` — data access is `ctx.sql` only | | **R3** no network | module code never calls `fetch()` or imports an HTTP client | | **R4** spine is sacred | module code never *writes* `_substrat_*` tables (reads are fine — timelines are projections) | | **R5** tables private | module code never references another module's tables in SQL | | **R6** one clock | time comes from `ctx.now()`, stable for the whole invocation | | **R7** no bare catch | an engine error is caught only inside `ctx.atomic` — a bare `catch` commits the engine's partial writes | | **R8** no `SELECT *` | an engine read names its columns — a star publishes whatever the physical table holds today, so a moved column reaches a vertical as wrong data rather than a throw | Table ownership is **derived, never declared** — a table belongs to whichever module's `CREATE TABLE` migration created it. There is deliberately no manifest field for it, because a second source of truth would drift and wave a real violation through. R5, R6 and R8 have an explicit, reviewable opt-out (`boundary-lint-allow R5` … `boundary-lint-end R5`) for a one-time extraction handoff, for code that must read the real clock, and for an engine's own maintenance read whose row never leaves it. There is no escape hatch for R1–R4, and deliberately none for R7 — a hatch there would only ever be used to silence the rule. The full reference is [`boundary-lint`](https://substrat.net/reference/boundary-lint.md). > **Warning — A green light it had not earned** > > Exit codes are load-bearing: `0` clean, `1` violations, and **`2` the linter could not do > its job** — no module code found, or no engines resolved, so an R5 pass would trivially > succeed. A linter that passes by finding nothing is worse than no linter, so it fails > loudly instead. ## Guard 03: three marks, or it is not generated A derived file is worth nothing if a reader cannot tell it apart from one a person maintains — and worth less than nothing if it *says* it is generated while nothing re-emits it. So a generated file carries all three of: 1. **The filename** — a `.generated.ts` suffix (or, for a document, a `` marker at the top. On a page with VitePress frontmatter it goes **directly after** the closing `---`, never above it: frontmatter has to be the first thing in the file, and a marker pushed above it stops being frontmatter and starts being rendered prose. [`agent-rules`](https://substrat.net/guide/agent-rules.md) is the page on this site that obeys the rule). This is the signal a reviewer reads in a diff without opening the file. 2. **A header naming the producer and the source** — so "where does this come from" is answered in the file itself. 3. **A `--check` re-emit in CI.** This is the only one that enforces anything. "Do not edit" is a request; the gate is what makes it true. `src/migrations.generated.ts` carried the comment for a year with no gate, and a hand-edit to shipped SQL passed every check in the repo. The **re-emit gates** today — the ones that regenerate a derived file and fail if the checked-in copy differs — are `lint:model`, `lint:permissions`, `lint:migrations`, `lint:api`, `lint:client`, `lint:conformance`, `lint:decisions`, `lint:playbook`, `lint:docs`, `lint:llms`, `lint:agent-rules`, `lint:launch`, `lint:plugin`, `lint:pins`, `lint:connector-grants`, `lint:auth-schema` and `lint:lake-schema`. CI runs more than those, and the difference matters when you are looking for the gate that holds a particular file: the checks that **emit nothing and simply refuse** are `lint:boundaries` (`tools/boundary-lint.mjs`), `lint:cycles`, `lint:deps` (an import whose package the workspace graph does not declare), `lint:tests`, and `lint:changelog --check` — which asserts a hand-written digest accounts for every merge in its range — and that no settled week is missing a digest altogether — while it could not re-emit the prose if it wanted to. `lint:scaffold` refuses too, and is the one that runs **off** the PR: post-release and weekly, because between a merge and the release that publishes it the scaffold template legitimately runs ahead of npm. A gate must also be the *only* thing that touches the file. A `pre*` hook that regenerates a gated artifact on the way past cancels its own gate — the hand edit is overwritten instead of caught, and the diff a reviewer would have read never appears. Emit on demand, and let CI be the one that says no. **The one exception, stated rather than hidden:** a file generated from a *remote* source cannot be re-emitted hermetically in CI, so it gets marks 1 and 2 plus a `GENERATED_AT` stamp instead of mark 3. And one accepted shape, not a category: a **JSON** artifact cannot carry a comment, so `model.json`, `openapi.json` and `.claude/launch.json` have mark 3 only, with their producer named in the gate list above and in the tool that emits them. An in-repo source with no gate is a defect, not a style. ## Guard 04: two descriptions that are allowed to disagree This is the quiet one, and it may be the most valuable thing here. Models are fantastic and *inconsistent*: they do not fail loudly, they make many small mistakes. You cannot prompt that away, so the architecture has to catch it. **Every defect worth catching is two descriptions disagreeing.** Once the implementation is derived from the model, the code is a *function of* the model and can no longer contradict it. So the second description has to be the tests — and they are only a second description if they were written from somewhere else. That makes the direction load-bearing: > **Code comes from the model. Tests come from the concept.** The concept is the human-approved prose (`spec/concept.md`); the model is the typed declaration (`spec/model.ts`). Two independent derivations of the same intent, and the disagreement between them is the entire product. A suite written *after* the handlers can only agree with whatever got built. It will pass, it will look thorough, and it will ratify a wrong model perfectly and forever. That is the failure mode every AI-generated test suite has, and almost nobody names it. The mechanical rule that keeps it honest: **literal inputs, literal outputs, import nothing from `spec/`**. A test that builds its input from the schema it is meant to judge *cannot* disagree with that schema — it is the mirror again, one level down. `pnpm lint:tests` enforces it. Two consequences worth stating plainly: - **The build may not edit its own oracle.** Tests are written and approved first; the build's job is to make them pass. - **If the concept doesn't say what should happen, that is a gap in the concept.** The agent says so and stops, rather than inventing an answer and thereby making it agreed. ## Guard 05: the two human checkpoints Two things stay under human review even in a fully agent-driven shop: 1. **Schema migrations.** The blast radius of a bad migration is data, not pixels. Migrations are plain, reviewable SQL, journaled per module and append-only — a shipped version is never edited. The agent writes them; a person approves them. 2. **Permission definitions.** Permissions are declared with human-readable descriptions, and every decision carries a proof path, which makes "who gains what, where" a diff rather than an archaeology project. `pnpm lint:permissions` renders each vertical's `PERMISSIONS.md` from the same `MODULES` and `ROLES` the seed exports, and CI re-emits with `--check`, so a widened role cannot merge without appearing in the diff. Both are still a *human* reading a diff. CI going red is what makes the reading unskippable; it is not itself the approval. Everything else — screens, workflows, operations, reports — iterates at agent speed with contained blast radius: the worst a bad operation can do is fail inside its own scope, audited. ## Guard 06: a running copy of production to be wrong in Reviewing a migration diff is a human checkpoint. Clicking a URL on the pull request and *watching that migration run against a copy of the real data* is what makes the checkpoint honest. Open a PR and the platform forks the production scope, binds the version that PR just pushed, gives it its own hostname, and posts the URL on the PR; closing the PR reaps it. Successive pushes to the same PR roll their migrations **forward on one copy**, exactly as a real upgrade would. When a bind crosses a migration-digest boundary the pre-migration data is snapshotted first — and the digest comparison is the gate, not a flag someone remembers to pass, so the safety net is on precisely when it matters and absent on a code-only rebind. *Version 2 landing on version 1's live data is the single most common way a generated app dies.* This is the machinery that makes it survivable. See [Environments & previews](https://substrat.net/guide/environments-and-previews.md). ## The receipt the guards leave behind The six guards above are a commit's problem. This one is a reader's: every guard above is invisible from the outside, and none of them leaves anything a person who does not have the repo can look at. A scope *is* a Durable Object with its own SQLite, so another tenant's row is not absent-by-predicate but absent-by-construction — the strongest claim here, and the hardest one to show anybody. A buyer cannot see an absence, and an auditor cannot file one. `pnpm lint:conformance` renders a `CONFORMANCE.md` beside each package's `PERMISSIONS.md`. Where the permission snapshot is a statement of intent, this is what is asserted against it, and the two halves are deliberately not mixed: 1. **Kernel-enforced properties**, cited **once** — cross-tenant addressing failing closed, the envelope stamped kernel-side, a narrowed grant that does not widen — each naming the contract-suite test that verifies it against *both* adapters. 2. **That app's own entity checks**, covered and uncovered **by name**. An operation that should check per-entity but checks at the node passes for anyone holding the key anywhere in the scope, with every test green; only a behavioural pair separates the two, and the pair is generated from the declaration rather than written by hand. The count discipline is the design. The tempting version multiplies every endpoint by every tenant and reports a four-figure assertion count — but cross-tenant isolation is one kernel fact, and restating it per endpoint measures the same thing repeatedly. An auditor who notices that discounts the whole document, which leaves it worth less than the absence it replaced. So the kernel section carries no per-app number at all. It is a statement of what is asserted, not a record that it passed. CI going red is what makes it true — the same standing `PERMISSIONS.md` has. ## Where this is honest about itself - **An agent can still write a bug.** The guards make one class unreachable and give a second class something independent watching. They do not make generated code correct. - **The egress sandbox has a documented hole.** Outbound traffic is bounded by a [declared per-version allowlist](https://substrat.net/concepts/platform.md) enforced at the egress seam, but Durable Object subrequests are a known gap. Don't call it airtight; it isn't yet. - **A red build is not an approval.** Both checkpoints are still a human reading a diff. CI going red is what makes that reading unskippable — it is not itself the sign-off. - **Enforcement is a slow argument.** It asks a buyer to follow a claim about runtime architecture before they can price it. Guard 07 makes the claim legible and citable; it does not make it fast. An inherited certification is the version a procurement officer prices in one sentence, and it isn't there yet. - **A receipt is not an audit.** `CONFORMANCE.md` is generated from the repo's own declarations and verified by the repo's own tests. That is exactly as much independence as a self-signed statement has — which is more than nothing, and less than a third party. The rest of what is missing is on [What Substrat doesn't have (yet)](https://substrat.net/guide/what-substrat-lacks.md). --- # Running locally Source: https://substrat.net/guide/running-locally.md [Getting started](https://substrat.net/guide/getting-started.md) builds one host in one script. This page is the other end: the **entire** flow — a vertical, the shared control plane, and the admin console — running together on your machine with `pnpm dev` and nothing but local SQLite. No cloud account, no Docker, no second datastore. > **Tip — This is a monorepo command** > > `pnpm dev` at the root of the [substrat monorepo](https://github.com/substrat-run/substrat) > runs the Callout demo vertical (`demos/callout`) wired to the console (`apps/console`). It is > the reference for what a local stack looks like, not a published tool. ## One command ```sh pnpm dev ``` It ends on a banner telling you where to go: ``` substrat · local stack — one process, one SQLite dir ──────────────────────────────────────────────────── ▶ Console (open this) http://localhost:5272 ▶ Portal — Callout http://localhost:5271 vertical API http://localhost:8871 control plane API http://localhost:8788 ──────────────────────────────────────────────────── data …/demos/callout/.data ``` Open the **console** to act as the platform operator (tenants, scopes, entitlements, suspend). Open the **portal** to act as a tenant's user (log in as a persona, do work). ## What is actually running The surprising part — and the thing that makes the flow real rather than mocked — is that there is **one backend process**. The vertical API and the control plane are two HTTP listeners over the **same `SqliteScopeHost`**, which owns both the directory and every scope's data. Two browser apps (Vite dev servers) sit in front, each proxying `/api` to its own listener. ```mermaid flowchart TB subgraph browser["Your browser"] C["Console UI
localhost:5272"] A["Callout app (portal)
localhost:5271"] end subgraph proc["One Node process — pnpm dev"] direction TB CP["control-plane router
:8788"] V["vertical API (Hono)
:8871"] H["SqliteScopeHost
the shared host"] CP --> H V --> H end I["dev-issuer (OIDC)
:8879 — picks a persona"] subgraph data["demos/callout/.data — one SQLite directory"] D[("_directory.sqlite
tenants · scopes · roles · entitlements · audit · identity links")] S[("<tenant>__<scope>.sqlite
one file per scope — its data + outbox")] end C -->|"/api → :8788"| CP A -->|"/api → :8871"| V A -.->|"login redirect"| I V -.->|"verify ID token"| I H --> D H --> S ``` Because the control plane and the vertical share **one host**, they share **one directory**. That is why a suspend in the console immediately fails the portal's next action closed: `getScope` reads the scope's status from `_directory.sqlite` on every call, and the console just wrote to that same row. There is no sync, no second copy — it is one `UPDATE` and one `SELECT` against the same file. ### The processes | Port | Process | What it is | |---|---|---| | `5272` | Vite dev server | The **console** — the platform operator's admin UI | | `5271` | Vite dev server | The **portal** — Callout's tenant-facing app | | `8871` | Node (Hono) | The **vertical API** — resolves a user, `getScope`, invokes operations | | `8788` | Node (Hono) | The **control plane** — the audited directory surface the console drives | | `8879` | Node (dev-issuer) | The **local login** — a real OIDC provider whose `/authorize` lists Callout's personas | The two Hono listeners are the *same process* sharing one host; the dev issuer is its own Node process, and the two Vite servers are separate again. All five are launched and torn down together by `pnpm dev`. ### The databases Everything lives under `demos/callout/.data` as plain SQLite files (WAL mode — the `-wal` / `-shm` siblings are SQLite's, not yours to touch): | File | Owned by | Holds | |---|---|---| | `_directory.sqlite` | the shared host | The directory: tenant registry, scope records + lifecycle status, roles, entitlements, tenant-level permission tuples, and the admin audit log | | `__.sqlite` | the shared host | One per scope — that scope's own tables, permission tuples, and event outbox. Isolated: a scope is its own database and consistency domain | There is no credentials database. Callout is OIDC-only: accounts live at the issuer, and the directory holds only the *link* from each issuer subject to a principal — the seed writes it from `src/personas.ts`, the same array the dev issuer's picker lists. Locally that issuer is [`@substrat-run/dev-issuer`](https://substrat.net/reference/dev-issuer.md) on `:8879`, started by the same `pnpm dev`. Debugging is opening a file: ```sh sqlite3 demos/callout/.data/_directory.sqlite 'SELECT slug, status FROM scopes;' ``` Delete the `.data` directory to reset the world; it re-seeds on the next boot. ## Letting an agent run it Every demo and every scaffolded project ships a `.claude/launch.json`, so [Claude Desktop](https://code.claude.com/docs/en/desktop) starts the dev servers itself, opens the web app in the Browser pane, and — with `autoVerify` on — screenshots and checks for errors after each edit it makes. This is worth more here than in most projects. A demo's scenario test composes the host directly and **never boots `src/server.ts`**, so a green suite says nothing at all about the HTTP layer. The Browser pane is the reliable way to drive the part the tests skip. Each process gets its **own** entry rather than the single `concurrently` pair `pnpm dev` runs, which is the point: Claude can attach the Browser to the web port while reading the API's log independently. Callout's: ```jsonc { "version": "0.0.1", "configurations": [ { "name": "issuer", "runtimeExecutable": "pnpm", "runtimeArgs": ["run", "issuer"], "port": 8879, "autoPort": false }, { "name": "api", "runtimeExecutable": "pnpm", "runtimeArgs": ["run", "server"], "port": 8871, "autoPort": false }, { "name": "web", "runtimeExecutable": "pnpm", "runtimeArgs": ["--dir", "app", "dev"], "port": 5271, "autoPort": false } ] } ``` Three entries, and no `env` block — there is no dev header to switch on. The first entry is the **local login**: [`@substrat-run/dev-issuer`](https://substrat.net/reference/dev-issuer.md), a real OpenID Connect provider whose only shortcut is that `/authorize` lists the vertical's personas (`src/personas.ts`) instead of asking for a password. Picking a name there *is* the production round-trip — the API is an ordinary relying party against whatever `OIDC_ISSUER` names, so the login you exercise in dev is the one a deployment runs, and moving to a real issuer changes configuration, not code. A script that needs to act as someone mints a token at the issuer, never in the vertical — a JSON body naming the subject, and the returned `access_token` is the bearer: ```sh curl -XPOST localhost:8879/dev/token -d '{"sub":"dev|anna"}' # → { access_token, id_token, … } ``` ### These files are emitted — don't hand-edit them The topology is declared once in the `substrat.devServers` block of each project's `package.json`, and the launch file is generated from it by `pnpm lint:launch` (CI runs `--check` and fails on drift). A declaration names the env var that moves a port and the file that binds it; the **number is read out of that file**, so moving a port means editing `src/server.ts` or `app/vite.config.ts` and re-running the emitter — never editing the JSON. The same block carries `requires` — the keys a process cannot start **without** locally, checked by `tools/env-preflight.mjs` before the server boots. It is deliberately *not* emitted into `launch.json`: a launch file starts a server, and what a server needs in order to start is not something a client-specific adapter should hold. Note this is a different question from `envSpec`'s `required`, which means required to **deploy** — a hosted install receives most of its config through per-scope delivery, so keys that are optional there can still be mandatory on your machine, where no such delivery exists. ### Gotchas - **Open the session in `demos/`, not the monorepo root.** Preview servers use the selected folder as the working directory and do not scan subfolders, so a session opened at the root finds no configuration. - **`autoPort: false` everywhere, deliberately.** Every demo vertical is OIDC-only and redirects to a fixed callback, so a silently reassigned port would break the *login*, not the boot — a much worse failure to debug. The cost is that a genuine clash is fatal: two demos declaring the same port cannot run at the same time without `PORT=… WEB_PORT=… ISSUER_PORT=…` — all three, because every demo's dev issuer sits on `:8879` by default, so moving only the API and the web port still leaves the second issuer dying on `EADDRINUSE`. - **A process can bind more than the port it declares.** Callout's `api` entry starts the vertical API on `:8871` *and* the co-located control plane on `:8788`; only the first is declared, because only the first is the one to attach a browser to. If `:8788` is taken — by another project, or a stray `pnpm dev:connected` — the entry dies on `EADDRINUSE` for a port Claude is not watching, which reads as a server that simply never came up. `CP_PORT=…` moves it. - **An entry declares a port, not a URL.** The Browser pane opens that server's bare origin, and nothing in the file can deep-link a path or a query under it — so a page that is not the app's root is somewhere Claude navigates *after* the preview comes up. The one worth navigating to by default is **`/api/docs`**, which [`demos/meridian`](https://github.com/substrat-run/substrat/blob/main/demos/meridian/src/docs.ts) and `demos/manyfold` serve: Scalar's API reference over that vertical's own `/openapi.json`, from that vertical's own origin. No CDN — the renderer is a pinned bundle served as a local asset — and no proxy, so a try-it request is same-origin, the browser attaches the session cookie you logged in with, and every call executes as the principal you actually are. A 403 there is the permission system working. - **No secrets in `launch.json`** — it is committed. Desktop also does not inherit your full shell environment, and `env` in `~/.claude/settings.json` reaches *sessions* but not dev servers. Put values in **`.dev.vars`** in the project directory instead: it is gitignored, `wrangler dev` already reads it, and the `server` script loads it with `--env-file-if-exists`, so one file serves every way of starting the vertical. A shell variable still wins over it — but only in a terminal, which is exactly the gap that makes the file the reliable answer for Desktop. - **A fresh worktree is a fresh checkout — it has no gitignored files.** Desktop gives every session its own [worktree](https://code.claude.com/docs/en/worktrees), and without help a new session's first `pnpm dev` fails for reasons that read as code problems: a missing OIDC client, an empty `PLATFORM_SECRET`, a demo asking for a model key. The monorepo's root `.worktreeinclude` names the gitignored files a worktree needs in order to run, and Claude Code copies them from the main checkout when it creates one: `secrets/*.env`, every `.env` and `.dev.vars`, and Callout's local `wrangler.jsonc`. What it deliberately does **not** copy is `.data/` — each worktree seeds its own SQLite on first boot, so a session starts from the seed world rather than inheriting another session's tenants, logins and half-run scenarios. `node_modules/` and `dist/` are not copied either: run `pnpm install` and `pnpm build` in the new tree. A worktree you create yourself with `git worktree add` gets none of this — copy the files by hand or start the session through Desktop. - **Claude curling its own API from Bash may fail.** The sandboxed Bash tool still blocks outbound TCP to `localhost` ([claude-code#28018](https://github.com/anthropics/claude-code/issues/28018)). The Browser-pane path is separate and works; wiring up Bash-side `curl` is a deliberate `excludedCommands` entry, not something to discover mid-session. ## Two audiences, one directory The console and the portal are not two views of the same app — they are two **audiences**: - The **console** is the platform operator. It reaches every tenant, and its actions (suspend a tenant, grant an entitlement) are cross-tenant. This is the surface [the platform layer](https://substrat.net/concepts/platform.md) describes. - The **portal** is one tenant's user, confined to their scope by the identity they logged in with. Anna sees ElMontage; Mallory sees a different tenant entirely. From a scope's row in the console you can click **Portal ↗** to jump to that scope's app — the local stand-in for the production hostname router that maps a domain to `(tenant, scope, vertical)`. ## Adding tenants and scopes Everything the console can do, it does against the running directory — so it is the fastest way to change the local world: - **New tenant:** the console's Tenants view has a *Create tenant* dialog. It mints a ULID and calls the same audited `createTenant` the platform uses. - **Grant/revoke entitlements, suspend, archive:** all live in the console and take effect immediately, because they write the shared directory the portal reads. - **New scope:** provisioning a scope is on the control-plane API (`POST /scopes`) but does not yet have a console button — the demo seeds its scopes in `demos/callout/src/seed.ts`. Add one there, or `curl` the API with a platform-actor header. ## Adding another application A "new application" is a new **vertical**. Today each of the eight demo verticals (`demos/{callout,handlebar,manyfold,meridian,shop,ticket0,tock,todo}`) ships its own dev server — Callout's is `demos/callout/src/server.ts` — and each composes its engines + module into a host. (A ninth directory, `demos/auth-server`, is a shared OIDC provider, not a business vertical.) To scaffold one, follow [Getting started](https://substrat.net/guide/getting-started.md) with the engines you need, and [Deploying a vertical](https://substrat.net/guide/deploying.md) when it's ready to ship. What is **not** wired yet is running several verticals against **one** shared console locally — that needs each vertical to register into a *separate* control-plane process over HTTP, rather than co-locating the directory in its own host as the demo does for convenience. Until that seam exists, the local stack is one vertical + the console; the console's fleet view is designed for the many-vertical world it will grow into. ## The faithful topology: `pnpm dev:connected` `pnpm dev` co-locates the control plane and the vertical in one process for speed. To run the shape production actually uses — a **separate** control plane that the vertical *registers into* and is *gated by* — use: ```sh pnpm dev:connected ``` This starts three things: a standalone control plane (its own process, on `:8788`), the Callout vertical in **connected mode**, and the console pointed at that control plane. On boot the vertical registers its tenants and scopes into the control plane over HTTP; before every request it asks the control plane "is this scope still active?" So when you suspend a scope in the console, the vertical's next action fails closed — the same outcome as the co-located stack, but now crossing a real process boundary, exactly as it would cross a deployment boundary in production. ```mermaid flowchart LR CO["Console
:5272"] -->|writes| CP["Control plane
:8788 — own process"] V["Callout vertical
:8871 — own process"] -->|"register + gate (HTTP)"| CP V -->|local execution| DB[("scope SQLite")] ``` The seam is `ControlPlaneClient` from `@substrat-run/control-plane-api`: `createTenant` / `provisionScope` / `grantEntitlement` to register, and `assertScopeActive` to gate. One deliberate limit — the control-plane HTTP surface exposes lifecycle and entitlements but **not role or grant writes** (those are the permission-diff human checkpoint), so a connected vertical keeps its permission model local while the shared plane is authoritative for tenant/scope lifecycle and entitlements. Unlike the quick `pnpm dev` (which trusts a dev-actor header), the connected control plane runs **real staff auth** — the console shows a sign-in screen. Sign in with the seeded operator: ``` markus@substrat.run / substrat123 ``` Auth is Better Auth behind a provider-agnostic seam (`sessionPlatformAuth` + a staff allowlist), so who authenticates staff can change without touching the console or the router. The vertical registering its scopes is a *service*, not staff — locally it uses the dev-actor header as a stand-in for a real service credential. ## How production differs Co-location is a local convenience, not the topology; `pnpm dev:connected` above is the faithful shape on one machine. In production the control plane is its **own deployment** and each vertical is a **separate deployment**, all reaching one durable directory — the same surfaces you see here, split across processes and hosts. The SQLite adapter you run locally and the Cloudflare adapter you deploy on are the same kernel above [the scope-host contract](https://substrat.net/concepts/scope-host.md); only the composition root changes. Getting a vertical from this laptop to that production topology is its own step — the `substrat` CLI pushes a bundle, and an admission in the console lets a scope serve it. See [Deploying a vertical](https://substrat.net/guide/deploying.md). ## Next steps - [Tenants & scopes](https://substrat.net/concepts/tenancy.md) — the tenancy tree the directory records. - [The platform layer](https://substrat.net/concepts/platform.md) — what the console is a thin client over. - [Operations & the scope host](https://substrat.net/concepts/scope-host.md) — the seam that makes SQLite-local and Cloudflare-deployed the same code. --- # Deploying a vertical Source: https://substrat.net/guide/deploying.md [Running locally](https://substrat.net/guide/running-locally.md) ends on a promise: the SQLite adapter you run on your laptop and the Cloudflare adapter you deploy on are the same kernel above [the scope-host contract](https://substrat.net/concepts/scope-host.md) — *only the composition root changes*. This page is how you cross that gap. The tool is the **`substrat` CLI**, and the shape of the crossing is two ideas: a **push** uploads a version; a **promotion** points the `prod` channel at it and makes scopes serve it. For a vertical you own, both are yours. The model this page assumes — push vs deploy, admission, the one `prod` channel, in-place updates, previews, backout — is written up conceptually in [the deploy model](https://substrat.net/concepts/deploying.md). For non-production environments (test, canary, PR previews) see [Environments & previews](https://substrat.net/guide/environments-and-previews.md). This page is the how-to. ## Push is not deploy — but promotion is yours The single idea to hold onto: > A push uploads a version; it does not serve. A **promotion** points the `prod` channel at a > version and makes scopes run it. For a **private** vertical — one you own, not listed on the > marketplace — a push lands **admitted** automatically and you promote `prod` yourself. So > `substrat push --promote prod` is a complete deploy, and merge-to-main can be the deploy. > (`prod` is the *only* channel — a test or preview environment is a > [scope with data](https://substrat.net/guide/environments-and-previews.md), not a second pointer.) There are two separate questions hiding in "can this go live", and Substrat answers them at two different boundaries (decision D-36): - **"May this code run on our infrastructure?"** — answered **mechanically** by the [sandbox contract](https://github.com/substrat-run/substrat/blob/main/docs/architecture/self-serve-deploy.md): the declared bindings, Workers-for-Platforms isolation, and quotas. This is **admission**, and for a private vertical the contract is the whole answer, so a push is admitted the moment it validates (an auto-admission note records that no human vouched). - **"May *other tenants* run this code against *their* data?"** — a human decision, and it only arises when you **publish** a vertical to the marketplace. That is where the staff gate lives now: `substrat publish` (the `setVerticalListed` decision) is a human vouch, and from then on that vertical's pushes land **pending** and its prod promotion is a staff decision again. A private vertical has exactly one tenant exposed to its code — the workspace that wrote it — so there is no one to protect from an unvetted bundle but yourself, and you are the human at your own checkpoint. This is still the same [two-checkpoint discipline](https://substrat.net/concepts/modules.md#two-human-checkpoints) that governs migrations and permissions: a promotion that changes the permission or migration surface is refused until you acknowledge the diff (`--ack-permissions` / `--ack-migrations`). One credential principle underpins all of it: **the author never holds a Cloudflare token.** The control plane holds the Workers-for-Platforms credential and does the upload; the CLI builds locally and POSTs a bundle. (Decision D-34.) ## Install The CLI is published to npm as [`@substrat-run/cli`](https://www.npmjs.com/package/@substrat-run/cli) (Apache-2.0): ```bash npm install -g @substrat-run/cli # or: pnpm add -g @substrat-run/cli ``` That gives you the `substrat` bin. Inside this monorepo it is also wired at the root as `pnpm substrat`, so the examples below work either way. ## Sign in — `substrat login` ```bash substrat login ``` The default is a **browser loopback login**: the CLI starts a one-shot server on `127.0.0.1`, opens your browser to the control plane's CLI broker (`{cp}/auth/cli`), which signs you in through [AuthHero](https://substrat.net/concepts/identity.md) and redirects back with a PKCE-bound `code`. The CLI exchanges the code for a session token and stores it in `~/.substrat/config.json`. The token never transits a URL — only the code does — and the loopback server accepts exactly one callback, then closes. For CI, where there is no browser, the credential is a **tenant-scoped push token** (`spt1.…`) in the `SUBSTRAT_SERVICE_TOKEN` environment variable — the dashboard's one-click CI setup mints and installs it for you ([below](#deploy-from-ci)). Either way, auth resolves in this order at push time: explicit `--token` / `SUBSTRAT_SERVICE_TOKEN` → a stored browser session → a stored service token. Two consequences worth knowing: - An **exported `SUBSTRAT_SERVICE_TOKEN` wins over a fresh `substrat login`** — the session is ignored. The CLI warns when this happens; unset the variable to use the session, or pass `--token` to make the override explicit. - The control-plane URL resolves `--cp` → `SUBSTRAT_CP_URL` → the stored config, and it must be the **API base** — `https://console.substrat.net/api`, not the console page. Getting HTML where JSON was expected is the symptom, and the CLI's error message names it. You are always authenticated **as yourself** — a push is attributable to the human or service that ran it, never a hand-picked actor. ## Your workspace A vertical is owned by a **workspace** (a tenant), not a bare user — the same account you sign into the [dashboard](https://substrat.net/platform/dashboard.md) with. On `login` the CLI resolves which workspaces you belong to and stores a default; `substrat whoami` prints them: ```bash substrat whoami # signed in as you@acme.com # acme-co (Acme Co) ``` Which workspace a **push** acts for is pinned per project, not per machine: a `"substrat": { "tenant": "acme-co" }` block in the vertical's `package.json` (the first interactive push offers to write it for you; `--tenant` / `SUBSTRAT_TENANT` override). The stored login default is deliberately *not* used for pushes — the first push of a slug claims it for a workspace, and a global default silently pointing at the wrong one would claim it for the wrong owner. You never type your workspace *into* a slug — the control plane forms the prefix for you (next section). New here? Sign up once in the dashboard to create your workspace, then the CLI just works. ## Ship it — `substrat push` ```bash cd my-vertical && substrat push ``` Run it from the vertical's directory and it needs no flags: the **slug** and **name** come from a `"substrat": { "slug", "name" }` block in `package.json` (or are derived from the package name), and the **version** defaults to the registry's latest, patch-bumped — so you never hand-track it. Override any with `--slug`, `--name`, or `--version`. What you declare is **what your vertical needs from the runtime, in Substrat terms** — a `runtimeNeeds` block in the same `substrat` section. You never write Cloudflare deploy config; the CLI derives it at push time: ```json { "substrat": { "slug": "helpdesk", "runtimeNeeds": { "entry": "src/worker.ts", "needsNodeCompat": true, "build": "pnpm --dir app build && node scripts/gen-assets.mjs", "stores": [ { "binding": "SCOPE", "class": "ScopeDO" }, { "binding": "AUTH", "class": "IdentityDO" } ] } } } ``` - `entry` — your worker's entry module. - `needsNodeCompat` — set it if you use Node built-ins at runtime (Better Auth does). - `build` — an optional command to run before bundling (an SPA build, asset generation). - `assets` — the built front end (`directory`, `notFoundHandling`, `runWorkerFirst`), uploaded to the runtime's asset store as native assets. A vertical with an `app/index.html`, no `assets` block, and no inlined-assets module under `src/` (the `gen-assets.mjs` pattern above, which serves the app from the worker) is **refused at push**: the deploy would otherwise succeed and 404 on `/`. The full condition, the declaration it prints, and the `--allow-unserved-ui` override are in [the CLI reference](https://substrat.net/reference/cli.md#push). - `stores` — your vertical's **own** durable state classes and the binding each is reached through (`env.SCOPE`). This is the whole vocabulary on purpose: the sandbox contract refuses everything except your own stores anyway, so there is nothing else to say. The compatibility date is the **platform's** runtime baseline — you never pick it. (If you already maintain a `wrangler.jsonc` — the demos in this repo do, for local `wrangler dev` — the CLI still reads it when no `runtimeNeeds` block is present. When both exist, `runtimeNeeds` wins.) The same `substrat` block also carries your **install spec** — what the dashboard grants and renders when someone installs your vertical, no catalog edit needed: - `entitlements` — the full entitlement set an install grants the installing tenant (your own SKU flag plus any engine you compose, e.g. `["helpdesk", "workorder"]`). **Absent, the platform derives your slug** — so a vertical whose module gates on `entitlementKey: ''` (the convention) installs entitled to itself out of the box. Declare the list explicitly the moment you gate on anything else. - `ownerGrants` — the permissions the installing owner holds inside the fresh scope on day one. - `envSpec` / `surfaces` — the install form's config fields and your declared UI surfaces. The same block is also where you **request platform capabilities** — the things the sandbox deliberately won't let you bind for yourself: - `sendsEmail` — set it (`"sendsEmail": true`) if your vertical sends transactional mail (password resets, verification, invites). You get **no** `send_email` binding: a dispatch script can't hold one, and the sandbox refuses it. Instead the platform sends on your behalf through a relay, and your code just uses the ordinary `EmailTransport` seam — the auth-server's Better-Auth `sendResetPassword` callback is the reference. The declaration is a **request**; it does nothing until a staff member grants the `emailSender` capability in the console. - `provisions` — the verticals your manager app creates tenants of (the tenant-provisioner request), turned on the same way (`setVerticalTenantProvisioner`). - `usesModels` — set it (`"usesModels": true`) if your vertical answers with a language model. This is the one request answered with a **binding** rather than a relay: the platform appends `env.AI` — its own model runtime, on its own AI account — to your script. You still hold no credential, and you still cannot bind `ai` yourself; the allowlist refuses that, and the platform adds the binding after checking what *you* declared. Two switches have to agree for it to appear: a platform-side kill-switch for the whole fleet, and this version having asked. A version that never declared it gets no binding at all and falls back to whatever model key its own env carries. `demos/ticket0` is the reference declaration. A capability request is refreshed on every push and confers nothing by itself; what turns it on is a flag your push can never set or keep. For `sendsEmail` and `provisions` that flag is per-vertical, so shipping code can never quietly acquire outbound authority. `usesModels` is the one where the platform's switch is fleet-wide rather than per vertical: with models on, adding the declaration and pushing is enough to get `env.AI` — which is exactly why the declaration lives in the version rather than in a setting, so it shows up in the diff. This is the wiring model: **declare the request, get it granted, call what the platform hands over — never bind the raw resource.** Entitlements are delivered to your vertical **with provisioning** and projected locally; your per-operation gate fails closed on anything the tenant doesn't hold. If a live install ever ends up missing one (granted later, or repaired), the control plane re-delivers through your `/internal/reconcile` route — part of the required `/internal` contract for hosted verticals (`/internal/provision`, `/internal/reconcile`, `/internal/configure`, and the snapshot/export/restore family). A vertical without `/internal/reconcile` cannot be repaired in place. Repair is also what makes **a promoted version reach the installs you already have**. Every scope carries a receipt, `provisionedVersionId` — the version its provision hook last ran against. Once the pushed version is promoted (or a scope is bound to it), the platform sweep compares that receipt with the version the scope now serves, and re-runs `/internal/reconcile` on every active install that is behind — a push alone serves nothing, so it repairs nothing: a `null` receipt (a scope provisioned before the platform recorded one) counts as behind rather than up to date, forks and previews are skipped so a hook never mints a second copy of anything against somebody else's data, and a reconcile that fails is left unmarked and retried on the next pass. A reconcile runs *both* halves of a provision — the kernel's (roles projected, owner seated, entitlements re-delivered) and yours (`onProvision`) — so a service principal or site registration a new release adds in its hook reaches every existing install with no button and no call. The consequence for you: **`onProvision` is not once-per-scope.** It re-runs against scopes that were provisioned long ago, so everything it does must be idempotent — look before you mint, and treat "already there" as success. You do **not** hand-write those routes. The whole `/internal` surface — plus the `application/problem+json` error envelope the control plane relies on to read a failure (`code`, `detail`, and a module's own `reason`; see [API design](https://substrat.net/concepts/api-design.md#_5-failures-are-data)) — is authored once in [`@substrat-run/vertical-host`](https://substrat.net/reference/vertical-host.md) and mounted in one call: ```ts import { mountPlatformSurface } from '@substrat-run/vertical-host'; mountPlatformSurface(app, { platformSecret: (env) => env.PLATFORM_SECRET, hostFor, // (env) => your CloudflareScopeHost roles: ROLES, ownerRoleKey: OWNER_ROLE_KEY, onProvision, // your pending-owner / site-registry side effect — // idempotent: a reconcile re-runs it after every promote resolveOwner, // owner-of-record for a reconcile (omit ⇒ 501) onConfigure, // per-instance config store (omit ⇒ 501) // The owner seat as the platform may see it, and the claim link it may mint for one // that sits empty after the first-sign-in window (omit either ⇒ 501) ownerSeat: (env, ref) => identityDo(env, ref).ownerSeat(ref.scopeId), mintOwnerClaim: (env, ref, input) => mintOwnerClaimLink(identityDo(env, ref), ref.scopeId, input.origin), }); ``` Mounting it is what satisfies the contract — the routes cannot drift out of sync or ship without the error envelope, because there is only one copy. `create-substrat` scaffolds this call for you; the demos (`demos/meridian`, `demos/manyfold`) are worked reference implementations. The last two hooks are the **owner seat** rule. At provision the platform mints a principal for the installer and hands it to `onProvision` as `owner`, but it cannot hand over the login — your app authenticates at whatever issuer the tenant bound, and the platform does not know which `sub` that issuer will emit for this person. So the seat is minted **empty** and bound later by a verified subject. For **15 minutes after provision** (`FIRST_SIGN_IN_WINDOW_MS` in [`@substrat-run/vertical-auth`](https://substrat.net/reference/vertical-auth.md)) the first person to sign in claims it — the install flow, where the installer opens the app seconds later. After that a plain sign-in binds nobody: an instance nobody opened is not a seat anyone can take indefinitely. A closed window is not a lost instance — the seat stays **pending** until a claim binds it, and the dashboard's [Owner seat card](https://substrat.net/platform/dashboard.md#owner-seat) mints a short-lived claim link (under the platform secret, through your `mintOwnerClaim` hook) that only its holder can use. A re-provision keeps whatever window the seat has and never re-opens a claimed one. `mintOwnerClaimLink` does the token, the hash and the URL in one call, which is why the hook is a one-liner; the full rule is on the [vertical-auth reference](https://substrat.net/reference/vertical-auth.md#the-identity-directory). A push then: 1. **Builds the bundle** with `wrangler deploy --dry-run --outdir` against the derived config — running your `build` command first. workerd cannot bundle in the isolate, so the build always happens on your side; the endpoint only ever receives a *built* worker. 2. **Assembles the manifest** that travels with the bundle — your own store classes and bindings, the runtime baseline and flags, the entry module — from the same derived config the bundler consumed, so what you declared and what you shipped cannot drift. 3. **Computes digests** — manifest, permission (from the bindings), migration (from the DO classes) — the same digest-diff surface the checkpoints read. 4. **POSTs the bundle + manifest** to `{cp}/verticals/{slug}/deploy`, authenticated with your own credential. The endpoint validates your declared bindings against the sandbox contract — a customer bundle that tries to declare a `CONTROL_PLANE` binding or a platform secret is refused *before* it reaches the namespace — uploads to the `substrat-verticals` Workers-for-Platforms namespace under a `deploymentRef`, and records the version. For a **private** vertical that version lands **admitted** (the sandbox contract is the whole gate); a **listed** vertical's lands **pending** for staff admission. On success the CLI prints the version id, its admission state, and the `deploymentRef`: ``` ✓ pushed acme-co/helpdesk. version 01J… (0.2.1) is admitted; deploymentRef=acme-co-helpdesk-01j… promote it to a channel to go live (or push with --promote prod). ``` To make the same push a full deploy, add `--promote prod`: ```bash substrat push --promote prod # ✓ pushed acme-co/helpdesk. version 01J… (0.2.1) is admitted; deploymentRef=… # ✓ acme-co/helpdesk → prod now points at 0.2.1 ``` That is the shape the generated merge-to-main workflow uses: a private vertical's push is already admitted, so `--promote prod` succeeds immediately and the merge *is* the deploy. (A listed vertical's push lands pending instead, and the `--promote prod` in the same run is refused, naming the staff gate.) ### The `/` prefix You push a **bare** `--slug helpdesk`; the vertical's registry id is `acme-co/helpdesk` — your workspace slug, prepended by the control plane from your authenticated session. You never type it. The point is that the name is unique *by construction*: every workspace can own a `helpdesk` without a global land-grab, the same way project names are scoped to your account on Vercel. (This prefixes only the registry id and the `deploymentRef` — never an app's hostname, which is per *instance* and chosen when someone creates one.) Ownership is claimed on first push and fixed there: a later push to `helpdesk` from a different workspace is *its own* `other-co/helpdesk`, and no one else can push versions of yours. The flip side: **the credential picks the lineage.** A workspace-prefixed `acme-co/helpdesk` and a bare, platform-registered `helpdesk` are different verticals, even if they build from the same repository — pushes to one never update the other. Builder credentials (your login session, a push token) always land in your workspace's namespace; only platform staff address bare slugs. If a vertical seems to exist twice, or a push "succeeds" but the version never shows up where you're looking, check which lineage each side is resolving before anything else. ## Serving in place — updates carry data forward A vertical serves **in place** from one stable serving script, and version updates carry the scope's data **forward** (decision D-37). This is the part that makes "promote prod" safe to do from your laptop: - A prod promote **re-uploads** the promoted bundle onto the vertical's stable serving script. The scope's Durable Object and its SQLite **stay put** — data does not move, so an update is not a rebind-to-empty-storage. - Because the data is still there, the kernel's append-only **migrations run against production data**, exactly as they were designed to. A version is badged **code-only** or **schema-change** at publish so you know which kind of promote you are doing; a schema-change promote wants its migration diff acknowledged. - Secrets survive the deploy (`keep_bindings`). - **Backout is a time-boxed rewind.** Right before an upgrade migrates, the scope DO bookmarks the instant (Durable-Object point-in-time recovery). If a promotion goes wrong, an audited **rewind** rolls the scope's data back to that bookmark — time-boxed to **~24h** unless forced, the first-hours backout. The considered, longer path is [backup / restore](https://substrat.net/concepts/deploying.md#backup-restore-and-backout) (`substrat scope restore`). PITR rewinds the *whole* database, so a stale bookmark is refused where it cannot be skipped. Legacy scopes that predate the stable serving script hop onto it once with `substrat scope adopt-serving ` (export → restore → flip, data-first; idempotent, and `--vertical ` backfills every scope of a vertical). ## See what you've pushed — `substrat versions` ```bash substrat versions helpdesk # VERSION ADMISSION CHANNELS ID # 0.2.1 admitted prod 01J… # 0.2.0 admitted 01J… # 0.1.0 admitted 01J… ``` A bare slug again — the control plane resolves it under your workspace. The same view is in the dashboard's **Deployments** tab (below), so you can watch admission state and channels without the CLI. (If a prod promote's in-place serve ever failed, `versions` flags the split — the channel points at the new version but the scopes still run the old one — as `prod(promoted)` vs `prod(serving)`, so a stalled serve is visible rather than silently assumed live.) ## Promote to prod — `substrat promote` {#promote-to-prod} Once a version is **admitted** — which for your private verticals is immediately — you point `prod` at it yourself. `prod` is the only channel, so `--channel` defaults to it and you rarely type it: ```bash substrat promote helpdesk --version 01J… --ack-migrations ``` Promotion re-points the prod scope at a new version — a **rebind**: the same scope and data, serving new code. For a vertical you own privately you self-serve it (there are no *other* tenants to keep in lockstep — that concern, D-30, is a shared vertical's many tenants, which a private vertical cannot have). Every promotion appends to the vertical's channel history — what went live, what it replaced, who, and exactly when — which is what the dashboard's rollback picker reads and what a rewind anchors to. There is no `dev` or `staging` to promote to — a non-production environment is a [preview](https://substrat.net/guide/environments-and-previews.md), a scope with data at its own URL, not a second pointer at the same code. A promote to any channel but `prod` is refused, naming the preview command. The one thing a promote will stop for is a **changed surface**: a promotion whose permission or migration digest differs from what is live is refused until you acknowledge the diff (`--ack-permissions` / `--ack-migrations`) — read the diff it names first. That is the migration / permission checkpoint, applied at the deploy boundary. The staff gate returns only when you **widen the audience**: `substrat publish ` lists the vertical on the marketplace, and from then on its pushes land pending and its prod promotion is a staff decision again — because now *other* tenants can run your code against *their* data. ## Preview a pull request — `substrat preview` {#preview-a-pull-request} Before a change is merged you can see it running — the PR's code against a **fork of your production data**, on its own URL: ```bash substrat preview create . --tag pr-42 # push this tree, fork prod, serve the pair # ✓ preview 'pr-42' created → https://helpdesk-acme--pr-42.global.substrat.run substrat preview ls # what's live substrat preview delete --tag pr-42 # reap it (idempotent) ``` `create` pushes the working tree (so the version it binds is exactly the PR's code), forks your vertical's prod scope, binds the pushed version to the fork, and mints a non-canonical `--` hostname alongside your prod URL. Re-running the same `--tag` (what a new push to the PR does) **rebinds the new version onto the same fork**, so successive pushes roll their migrations forward on one copy — the rehearsal you actually want before a migration merges. `--refresh` starts over from a clean fork of prod. Every preview carries a TTL (`--ttl 72h` by default) so an abandoned one is garbage-collected even if it is never deleted. This is wired into the generated GitHub workflow for you: **opening or updating a PR creates or updates its preview and comments the URL; closing the PR reaps it.** If your vertical was set up before previews existed, re-run the dashboard's one-click CI setup (or copy the regenerated `.github/workflows/substrat-deploy.yml`) to pick up the PR jobs. A preview forks *your own tenant's* scope and serves no install, so you may preview a vertical you own whether it is **private or listed** — publishing widens who may *install* your code, not who may preview their own pending version (#513). A vertical's *first* environment, before any prod scope exists to fork, is `substrat preview create --tag … --empty` — a clean-room scope, no source (#514). The one hard limit is jurisdiction: forking pins the copy's *execution*, so an `eu`/`us` source scope is refused until Regional Services, the same residency gate as `scope pull`. The fork carries real data and its `--` URL is non-canonical and not public — treat it as production data. Previews are the substrate for every non-production environment — sticky-per-PR and per-build URLs, a long-lived test environment on a custom domain, canary rollout, a release candidate. Those patterns, and the CI recipe that wires them, are [Environments & previews](https://substrat.net/guide/environments-and-previews.md). ## Deploy from CI — the push token {#deploy-from-ci} Your laptop authenticates with a browser login; a pipeline cannot. The CI credential is a **tenant-scoped push token** — a long-lived machine credential, recognizable by its `spt1.` prefix, that authenticates as a *builder for exactly one workspace*. It can do what you can do as a builder — push, promote your private verticals (prod included), manage previews — and nothing more: it never reaches another workspace's namespace, never admits a version, and never promotes a **listed** vertical to prod (those stay staff decisions). It rides the same `SUBSTRAT_SERVICE_TOKEN` variable the CLI already reads, so the workflow needs nothing special. Two tokens deliberately do *not* fill this role: your login session is per-human and short-lived, and the platform's internal service token is staff-equivalent — it must never land in a customer repository (and would push to the wrong lineage anyway, per the previous section). You rarely handle the push token yourself. The [dashboard](https://substrat.net/platform/dashboard.md)'s Deployments view has a **one-click CI setup**: connect the GitHub App, pick a repository and branch, and it 1. mints the push token, 2. writes it as the repo's `SUBSTRAT_SERVICE_TOKEN` Actions secret (before the workflow, so the first run already finds its credential), and 3. commits `.github/workflows/substrat-deploy.yml` — which triggers that first run immediately. The committed workflow is the whole loop from this page: a push to the chosen branch installs dependencies (your lockfile picks the package manager), builds the workspace packages the vertical imports when it lives in a monorepo (install only *links* a sibling; its `dist/` is what the bundle resolves), gates the push (below), and runs `substrat push --promote prod` — for a private vertical, merge-to-main *is* the deploy; opening or updating a PR creates its [preview](#preview-a-pull-request) and comments the URL; closing the PR reaps it. A manual copy-paste path shows the same file if you'd rather commit it yourself. ### The gate before the push A push *is* a release: it uploads a bundle the platform admits and starts serving. So the workflow runs your own checks first, in a **Gate the push** step between the build and the upload — whichever of `typecheck`, `test` and `lint:boundaries` your package.json declares (the three a [scaffolded project](https://substrat.net/guide/getting-started.md) ships with), from the package's directory. A non-zero exit fails the job, and nothing is uploaded. Only *your package's* scripts, never the repo root's, even in a monorepo: the build step ahead of it builds the vertical's dependency closure and nothing more, so a root script is free to need a tool this job never built. This is the *only* place those rules run for a project that is not developed inside the Substrat monorepo, so it is worth having all three. `lint:boundaries` is the one you are most likely to be missing: ```json { "scripts": { "lint:boundaries": "substrat-boundary-lint" }, "devDependencies": { "@substrat-run/boundary-lint": "latest" } } ``` [`substrat-boundary-lint`](https://substrat.net/reference/boundary-lint.md) is what catches a module reaching for `cloudflare:workers`, another module's tables, or `Date.now()` before that code is serving traffic. A repo that declares none of the three still deploys — the file is regenerated into projects that predate the gate — but the run says so, loudly. If you never connect the GitHub App — you own your CI, you are not on GitHub-hosted runners, or you want the release-train shape instead of merge-deploys-prod — generate the same file locally: ```bash substrat init --ci github # merge to the deploy branch releases substrat init --ci github --release changesets # only a package.json version move releases ``` Both paths render from one generator, so the committed workflow and the generated one cannot drift. Two extras are opt-in through repository *variables* rather than a regenerated file: `SUBSTRAT_TEST_SCOPE_ID` makes every merge rebind a [long-lived test environment](https://substrat.net/guide/environments-and-previews.md#a-long-lived-test-environment), and `SUBSTRAT_PER_BUILD_PREVIEW` adds a frozen [per-build URL](https://substrat.net/guide/environments-and-previews.md#sticky-per-pr-and-per-build-urls) to each PR comment alongside the sticky one. See [`substrat init`](https://substrat.net/reference/cli.md#init). One thing to know while the first run is in flight: **a vertical registers on its first successful push**. Until then there is no version row in the dashboard to hang an error off — if nothing appears, the place to look is the repository's Actions tab, not the Deployments view. ## Watch it in the dashboard Everything above is mirrored in the [dashboard](https://substrat.net/platform/dashboard.md)'s **Deployments** view: the verticals your workspace has pushed, each version's admission state, and where `prod` points — with self-serve prod promotion for a vertical you own, the channel history behind the rollback picker, and a **Previews / Environments** surface for the non-prod work ([test env, PR previews, canary](https://substrat.net/guide/environments-and-previews.md)). Push from the CLI, manage from either. ## The whole path **push → (auto-admitted) → promote → serve in place** — laptop to production with the author never holding a Cloudflare credential, data carried forward across every update: | Step | Who | Where | |---|---|---| | `push` a bare slug → `/` version | you (the owner) | CLI | | admission — automatic for a private vertical (the sandbox contract *is* the gate) | the control plane | — | | promote `prod` (re-points your live scopes; the only channel) | you | CLI or dashboard | | serve in place — DOs/data stay put, migrations run forward | the vertical's serving script | — | | backout — time-boxed PITR rewind, then `scope restore` | you | CLI / dashboard | | resolve hostname → scope → dispatch | the [router](https://substrat.net/platform/router.md) | — | When you later `substrat publish` a vertical to the marketplace, the staff gate reappears at that boundary: its pushes land pending and prod promotion becomes a staff decision, because that is the point where *other* tenants run your code against their data. ## Where this is going Self-serve end to end for a vertical you own — push it, promote prod, serve in place with data carried forward — is what ships today. The remaining evolution is on the *publish* side: admitting an **untrusted, listed** builder's source safely — building a customer's code in a disposable sandbox so the digest checkpoints become verified rather than advisory — at which point even the marketplace staff gate can relax. That trust model is the [self-serve deploy design note](https://github.com/substrat-run/substrat/blob/main/docs/architecture/self-serve-deploy.md). --- # Environments & previews Source: https://substrat.net/guide/environments-and-previews.md A vertical you own has exactly **one channel — `prod`** ([the deploy model](https://substrat.net/concepts/deploying.md#the-one-channel-prod)). There is no `dev`, no `staging`. That is not a missing feature; it is the point. On most platforms an *environment* is a second deployment — another machine running other code. Substrat inverted both halves: code is shared and nearly free (one hibernating Durable Object class per version), and the isolation boundary is the **scope**. So the thing that separates one environment from another here is *which scope*, not *which deployment* — and a scope with its own data already has a name and a URL. A second pointer at the same code would add nothing. So a non-production environment on Substrat is a **[preview](#previews-the-non-prod-primitive)**: a scope with data, bound to a version, reachable at its own hostname. This page is how you use that one primitive to get every workflow the old three-environment ladder gave you — and a few it couldn't. ## The mental model: an instance is `(scope × version)` Three primitives, and the whole page falls out of how they compose ([preview & snapshots RFC](https://github.com/substrat-run/substrat/blob/main/docs/architecture/preview-and-snapshots.md) §2): | Primitive | Mutability | Git analogy | |---|---|---| | **version id** → a `deploymentRef` | **immutable** — a pushed build never changes | a commit **sha** | | **binding** (`bindScopeVersion`) — which version a scope runs | **mutable** by design | a **branch ref** | | **hostname** → resolves to a **scope** | stable; names a scope, never a version | a checkout path | A running app is a **scope bound to a version**, fronted by a hostname. The router resolves `hostname → scope`, then dispatches on *whatever version that scope is currently bound to*. Nothing more names an environment. This is why **prod is already a moving target**: the prod hostname points at the prod scope, and a [promote](https://substrat.net/guide/deploying.md#promote-to-prod) rebinds that scope to a new version. "test" and "prod" are the *same shape* — a stable scope + a stable hostname whose binding moves. The only thing that differs is **what triggers the rebind, and how gated it is**: - **prod** — the binding moves on an explicit, acknowledged [promote](https://substrat.net/guide/deploying.md#promote-to-prod); across a shared vertical's tenants it cascades. - **test** — the binding moves automatically on every merge to `main`, on one scope, ungated, driven from CI. **Diagram — what a hostname serves.** An instance is `(scope × version)`, and exactly one link in the chain is mutable. 1. **Hostname** (stable, a checkout path) — names a scope, never a version. 2. *resolves to* → **Scope** — one isolation domain, one database. 3. *currently bound to* — this is **the binding** (a branch ref): bindScopeVersion — mutable by design. It is the only mutable link, and the only thing an environment is. 4. → **Version id → deploymentRef** (immutable, a commit sha) — a pushed build never changes. **What moves the binding:** - **prod** — an explicit, acknowledged promote — cascades across a shared vertical’s tenants. - **test** — every merge to main, one scope, ungated, driven from CI. - **preview** — each push to the PR — its own scope, so it can never inherit prod’s binding. Nothing else names an environment. “prod” and “test” are the same shape — a stable scope and a stable hostname whose binding moves; only the trigger and how gated it is differ. ## Previews: the non-prod primitive A preview is a scope with data bound to a version, at its own `--` hostname. You create and manage them with [`substrat preview`](https://substrat.net/reference/cli.md#preview): ```bash substrat preview create . --tag pr-42 # push this tree, fork prod, serve the pair # ✓ preview 'pr-42' → https://helpdesk-acme--pr-42.global.substrat.run substrat preview ls substrat preview delete --tag pr-42 # reap it (idempotent) ``` `create` pushes the working tree (so the bound version is exactly this code), forks the vertical's prod scope, binds the pushed version to the fork, and mints a non-canonical `--` hostname. Two properties do the heavy lifting: - **A preview is idempotent per tag.** Re-running the same `--tag` — what a new push to a PR does — **rebinds the new version onto the same fork**. Successive pushes roll their migrations *forward* on one copy, which is the rehearsal that actually de-risks a release: the fork accumulates schema changes exactly the way prod will. `--refresh` starts over from a clean fork of prod. - **A prerelease label never steals a release coordinate.** A default preview push is labelled `-.` (a semver *prerelease*), and the registry's `nextVersion` only counts anchored `x.y.z` releases — so preview pushes are free: they never collide with, and never advance, the version your repo owns. (Pass `--version` to pin an exact label.) Every preview carries a TTL (`--ttl 72h` by default) so an abandoned one is garbage-collected even if never deleted — and reuse **renews** the deadline, so an actively-pushed preview never dies under you. `--ttl none` **pins** it until you delete it (see [the test environment](#a-long-lived-test-environment) below). > **Tip — Previews are for a vertical you own** > > A preview forks *your own tenant's* scope and serves no install, so a builder may preview a vertical > they own whether it is **private or listed** — publishing widens who may *install*, not who may > preview their own pending code (#513). What a preview will not do is fork a scope pinned tighter than > `global`-jurisdiction, or fork a first-party vertical you don't own. ### A vertical's *first* environment — the clean room A fork needs something to fork. A brand-new vertical has no prod scope yet — exactly when a throwaway environment is most useful. `--empty` provisions a **clean-room** scope instead of forking: module tables migrated, version bound, hostname minted, no data. ```bash substrat preview create . --tag sandbox --empty # a source-less environment ``` It follows the tenant-app hostname convention (`---.`) since there is no source URL to derive from. This is the "empty / seed data → clean-room preview" cell of the model — a first environment before any prod exists (#514). ## Sticky-per-PR **and** per-build URLs Because a hostname names a *scope*, and the router serves *that scope's current binding*, two URLs on one scope cannot resolve to two different versions. That single fact dictates the shape of good PR previews — and it is exactly git's branch-vs-sha discipline: | URL | Backed by | Behaviour | |---|---|---| | **Sticky PR URL** — `…--pr-42.` | one long-lived fork, **rebound every push** | always the latest push on the PR; bookmark it once. The *branch ref*. | | **Per-build URL** — `…--pr-42-.` | a **fresh** scope per build, **never rebound** | frozen to exactly that build, forever. The *sha*. | The sticky URL is what a reviewer watches; the per-build URL is what you paste into a comment when a regression appears in *one specific build* and must stay that build. The moving pointer is only safe *because* every build is also addressable immutably — you can always de-reference "the bug on the PR preview" down to a fixed artifact. The recipe is two `preview create` calls per push: ```bash substrat preview create . --tag pr-$PR # sticky: reused, renews its TTL substrat preview create . --tag pr-$PR-$RUN --empty --ttl 24h # per-build: fresh, short-lived ``` You do not have to write that yourself — [`substrat init --ci github`](https://substrat.net/reference/cli.md#init) and the dashboard's [one-click CI setup](https://substrat.net/guide/deploying.md#deploy-from-ci) generate the same workflow, which creates the sticky preview on every PR push and comments the URL back. The per-build call is **opt-in**, because a frozen scope per build is a real cost: set the repository variable `SUBSTRAT_PER_BUILD_PREVIEW` to `1` and the same workflow adds it, with the PR comment then naming **both** URLs — the one that follows the PR and the one frozen to this build. The per-build preview is deliberately `--empty` rather than a fork: it is thrown away within a day, so copying prod data into it on every push would be pure cost. If you need a frozen copy *with* data, that is a snapshot of the sticky preview, not a per-build one. ## A long-lived test environment A "test environment that always runs the latest `main`" is just a **pinned preview whose binding is rebound on every merge**, fronted by a custom domain. Nothing new — three primitives you already have: 1. **A durable scope.** Create it pinned so the GC sweep never reaps it: ```bash substrat preview create . --tag test --ttl none # forks prod; or --empty for a fresh one ``` 2. **A custom domain on it.** A hostname binds to *any* scope you own, not just prod — attach one with [`substrat scope domain`](https://substrat.net/reference/cli.md#scope-domain): ```bash substrat scope domain --domain crm-test.ahero.se # → verifying — publish the CNAME/TXT it prints; live when active ``` The scope is now protected from reaping while that domain resolves, exactly like a prod scope ([the reap guard](https://substrat.net/concepts/tenancy.md) counts any bound hostname). 3. **A rebind on every merge.** In the merge-to-`main` job, after the build, pin the test scope to the version you just pushed: ```bash substrat scope bind --version --snapshot ``` That is the whole thing. `crm-test.ahero.se` always serves the head of `main`; migrations roll forward on its accumulated data, rehearsing every prod migration a merge earlier; and prod stays behind its gated promote. All of it is manageable from the [dashboard](https://substrat.net/platform/dashboard.md#previews-environments) too — pin a preview, attach a domain, and (per app) bind a version — no CLI required. > **Info — Why "tracks main" stays a CI step, not a platform setting** > > Substrat *enables* this workflow; it deliberately does not *encode* it. A "this scope auto-tracks tag > X" toggle would be a new environment noun — the same temptation the retired `dev`/`staging` channels > gave in to, re-buried one layer down. A one-line `scope bind` in your merge job is more legible and > composes with any branching model. Revisit only if you have many test slots wanting a shared rule. ## Canary & pinned tenants — per-scope rollout The same [`scope bind`](https://substrat.net/reference/cli.md#scope-bind) primitive is the whole rollout axis for a **shared** vertical with many tenant scopes. A prod promote cascades every tenant to the new version; when you want *tenant A first*, bind that one scope ahead: ```bash substrat scope bind --version --snapshot # canary one tenant # … watch it, then promote prod to bring the rest along ``` `--snapshot` forks the scope's data before a migration-crossing bind, so a bad version leaves the prior one still runnable at its correct migration frontier — [fork-before-you-promote](https://substrat.net/concepts/deploying.md#previews-run-a-version-against-a-copy-of-the-data), applied per scope. A binding drift is native and expected: an install pins "prod-at-the-time", and `Update to latest` is the per-scope catch-up. The channel was always the *weaker* abstraction here — it can't express "tenant A first"; the binding can. ## The release workflow: pnpm + changesets The version your repo owns lives in `package.json`, not in a registry counter — so a merge does **not** move it, and only a release does. This is the workflow the generated CI encodes, and it maps cleanly onto the primitives above: | Moment | Version label | What runs | Where | |---|---|---|---| | **PR opened / pushed** | `-pr-.` | `preview create --tag pr-` | sticky `…--pr-`, a fork of prod | | **Merge to `main`** (changeset lands; version does **not** move) | `-test.` | `push`, then `scope bind ` | `crm-test.ahero.se`, the long-lived test env | | **Version PR** ("chore(release): version packages") | `-pr-.` | nothing special — it is a PR, so it gets a PR preview | that preview **is** the release candidate: the code that is about to become prod, on a fork of prod data, while rejecting it is still free | | **Version PR merges** (version moves) | `` exactly | `push --version `, then a tip-guarded `promote` — skipped if `main` already declares a newer version, so a queue that resumes out of order can never point prod at older code | prod | Note what row 3 does *not* need: a release-candidate channel, an `rc` tag, or any new noun. A version PR is a pull request, so the ordinary PR preview already runs the release candidate against forked prod data. The one thing that differs between it and the prod push is the version *label* — the code is the same tree. Two disciplines make this safe, and both are already true — this workflow just names them: - **A non-release push never claims a release coordinate**, because prerelease labels are skipped when the registry computes the next version. PR previews, the test env, and the release candidate all push freely without punching holes in your version sequence. - **The release candidate is the one moment** the *exact* artifact that will become prod runs against a fork of prod's data *while the change is still a PR* — i.e. while rejecting it costs nothing. It is also where the `--ack-permissions` / `--ack-migrations` acknowledgements are *earned* rather than typed at the same instant as the deploy. > **Tip — Don't hand-roll it** > > Generate it: > > ```bash > substrat init --ci github --release changesets > ``` > > [`substrat init`](https://substrat.net/reference/cli.md#init) and the dashboard's > [one-click CI setup](https://substrat.net/guide/deploying.md#deploy-from-ci) render the **same** workflow from the same > generator — merge-to-main release, the test-env rebind, and the PR previews with their comment. The > label discipline and the two-`preview`-per-push recipe are not something you should have to re-derive > per vertical, and the reason this command exists is that the first workflow we shipped got the label > part wrong: it pushed `--version 0.1.` on every run, claiming a real registry coordinate > each time and punching holes in the version sequence. ## See also - [Deploying a vertical](https://substrat.net/guide/deploying.md) — push, admission, promote prod, serve-in-place. - [The deploy model](https://substrat.net/concepts/deploying.md) — the concepts behind the one channel and in-place serving. - [Snapshots & test copies](https://substrat.net/concepts/snapshots.md) — the data-fork machinery a preview is built on. - [`@substrat-run/cli`](https://substrat.net/reference/cli.md) — `preview`, `scope bind`, `scope domain`, `promote`. --- # Ask the docs Source: https://substrat.net/guide/support.md The bubble in the corner is a [ticket0](https://github.com/substrat-run/substrat/tree/main/demos/ticket0) support desk — a demo vertical running on Substrat, whose knowledge base is this site's own `llms-full.txt`. Ask it something the documentation answers and it cites the section; ask it something the documentation does not, and it hands the conversation to a person instead of guessing. The widget is on every page of this site, and a conversation follows you from page to page: it began as a trial on this page alone, so the desk could be watched answering real questions from the real site before it went everywhere. A few things worth knowing: - The desk sees your message and nothing about you. Without a signed identity from the embedding site, a visitor is anonymous, and a conversation lives in your browser's local storage only. - Replies arrive by polling, so an answer can take a few seconds to land. - Cost is not hidden from the people running the desk. Every assistant turn records what it consumed, which is part of the point of the demo. --- # Tenants & scopes Source: https://substrat.net/concepts/tenancy.md Substrat tenancy is **two levels, tree-shaped**: the tenant is the business that pays you (a property-management firm, a retail chain, a publisher); beneath it are **scopes** — the housing associations it manages, its branch offices, its client companies, its brands. Users belong to the tenant, to a scope, or to several scopes with different roles. This shape recurs in essentially every vertical B2B product, and it is nearly impossible to retrofit — which is why it's kernel-owned and first-class rather than a convention. ## The entities ```ts import { tenant, scope } from '@substrat-run/contracts'; type Tenant = { id: TenantId; // branded ULID slug: string; // stable, URL-safe, unique name: string; status: 'active' | 'suspended' | 'deleting' | 'reaped'; // 'reaped' = terminal, bytes gone deletingAt: Instant | null; // set when it entered 'deleting'; null otherwise. The reap // sweep ages a tenant off this to decide the grace window. createdAt: Instant; }; type Scope = { id: ScopeId; // branded ULID — globally unique, not per-tenant tenantId: TenantId; parentScopeId: ScopeId | null; // v1: always null; the column exists so deeper // trees are additive, not a migration slug: string; // unique within the tenant kind: string; // YOUR vocabulary: 'brf', 'branch', 'brand', 'clinic'… name: string; status: 'provisioning' | 'active' | 'suspended' | 'archiving' | 'archived' | 'reaped'; // 'reaped' = terminal past 'archived'; DO storage gone, no restore storageShape: 'A' | 'B'; jurisdiction: 'eu' | 'us' | 'global'; // fixed at provisioning; 'global' is unconstrained vertical: string | null; // which vertical's deployment executes this scope verticalVersionId: string | null; // the registered version it runs (null if pre-registry) schemaVersion: string; // COUNT of applied (module, version) pairs, as a string. // '0' = provisioned, nothing applied yet. Compared against the // host's registered migration total to answer "which are behind". // Non-null when the scope's last migration attempt FAILED. The scope fails // closed and serves nothing; this is what stops it rendering as healthy. migrationFailure: { version: string; // the `module@version` that threw error: string; attempts: number; // consecutive failures lastAttemptAt: Instant; } | null; forkedFrom: ScopeId | null; // fork provenance: the scope this was copied FROM (importScope), forkedAt: Instant | null; // and when — both null for a normally-provisioned scope expiresAt: Instant | null; // when a fork stops being retained (GC); null = no expiry servingRef?: string | null; // the dispatch script this scope's data DO lives in (#286) archivedAt: Instant | null; // when it last entered 'archived'; null if never / on unarchive createdAt: Instant; }; ``` Design decisions worth knowing: - **`kind` is vertical vocabulary, not a kernel enum.** The kernel never branches on it. Call your scopes whatever your domain calls them. - **Scope IDs are globally unique**, so an event or an opaque ref never needs the tenant for disambiguation — but every kernel API still requires the pair `(tenantId, scopeId)` and cross-checks it. A confused-deputy bug in calling code **fails closed** instead of resolving to another tenant's scope. - **`jurisdiction` is fixed at provisioning, forever.** Data residency is decided when the scope is created, not toggled later. **Diagram — a tenant and its scopes.** - **Tenant** (billing · identity · the contract) — slug · status · one Identity DO for all its people. Holds no operational rows of its own. - It contains scopes: `stockholm` (branch, active · eu), `göteborg` (branch, active · eu), `malmö` (branch, provisioning). - Every scope is: its own database — one SQLite file, or one Durable Object; one operation at a time, run to completion; kind is your vocabulary: brf, branch, brand, clinic…; jurisdiction and bound version are fixed per scope. - **No query crosses these lines. There is no join between two scopes.** - parentScopeId is null on every scope today. The column exists so a deeper tree is an addition rather than a migration. The tenant is who you bill and who can log in. The scope is where data lives and where consistency is decided — which is why isolation is a property of the substrate here, not a WHERE clause somebody has to remember. ## One scope = one database = one consistency domain Each scope is an isolation domain with its own SQLite database and a strictly serialized executor: one operation at a time, run to completion. This gives module code single-writer simplicity — a read-modify-write inside an operation cannot interleave with another operation on the same scope — and it bounds the blast radius of any problem to one scope, not one customer. Serialization is **per scope, not per system**: a thousand scopes run a thousand operations at once, and only two operations *on the same scope* ever queue. What that means for throughput, and where reads go when a scope gets busy, is [Reads & scaling](https://substrat.net/concepts/reads.md). The granularity rule: **the scope maps to the consistency domain, not the tenant.** A tenant with 300 housing associations is 300 scope databases plus a lightweight tenant root, not one 300-times-hotter database. ## Provisioning ```ts // A scope belongs to a tenant, so the tenant record must exist first. host.admin.createTenant(actor, { id: tenantId, slug, name }); await host.provisionScope(actor, { tenantId, scopeId, storageShape: 'A', // optional jurisdiction: 'global', // optional (defaults to 'global'); immutable once set. // 'eu'/'us' are gated until Regional Services is in place. }); ``` Both take a platform `actor` (the control-plane staff subject) and are audited. Provisioning is **idempotent and journaled** — safe to re-run, safe to drive from a reconciliation sweep — and **requires an existing active tenant**, so a scope can never be orphaned. The host maintains a **directory** (a separate database) as the authoritative inventory of tenants and scopes; it's what `getScope` validates addressing against, and the input to migration sweeps and ops tooling. Provisioning is one step of a longer lifecycle — `active → suspended ⇄ active → archiving → archived` — which, along with entitlements, custom domains, and the rest of what sits *below* a vertical, is [The platform layer](https://substrat.net/concepts/platform.md). ## Storage shapes `storageShape` records how a scope's data is physically hosted in production: - **Shape A** — the scope's execution domain *is* the database (embedded SQLite as primary store). Right for small, document-centric, realtime-friendly scopes. - **Shape B** — the execution domain is a control plane (hot state: ACLs, entitlements, counters, locks) fronting a separate per-tenant database for bulk storage, read replicas, and export tooling. The choice is per-scope, fixed at provisioning, and invisible to module code — the scope-host contract is identical either way. On the pure-SQLite adapter both shapes are one SQLite file per scope. `storageShape` is not the only store a vertical can hold. Orthogonal to shape A/B, a vertical's manifest can declare stores the platform mints for it, one per *tenant*: - **`tenantStores`** — an independent SQL database (D1 on Cloudflare, a separate `.sqlite` file on the pure adapter), minted by `provisionTenantStore` and opened through `openTenantStore`. An own-store concept — an auth DB, say. - **`blobStores`** — object storage (an R2 bucket) behind the kernel's attachment surface, for the documents and images a scope's rows point at rather than contain. Both follow the same ownership rule, and it is the load-bearing part: **the builder supplies no id.** The vertical declares a *need*; the platform mints the database or bucket, holds the cloud credential, and attaches a binding to the serving script, re-derived from the ledger on every upload. A vertical is *handed* a store — it never names one, so it can never name someone else's. ## Addressing is capability-shaped ```ts const stub = await host.getScope(principal, tenantId, scopeId); await stub.invoke('workorder/create', input); ``` `getScope` mints a **capability stub** bound to one principal and one scope. From then on, tenancy is ambient: operations receive `ctx.tenantId` / `ctx.scopeId` / `ctx.principal` from the stub's context, and your business logic never passes IDs around. There is no parameter to get wrong, and nothing to forget to check — the scope re-validates every call against its own state anyway. --- # The platform layer Source: https://substrat.net/concepts/platform.md A Substrat vertical is not a SaaS product that happens to share some libraries. It is an application deployed **onto a platform** — and the platform below it owns tenancy, routing, custom domains, identity, entitlements, and the event history tier. This page is about that layer: what it gives you, what it deliberately does *not* share, and why the boundary falls where it does. > **Info — Status** > > The platform layer runs in production. Shipping on both adapters: **scope provisioning**, > the **directory**, the **tenant registry**, **tenant + scope lifecycle transitions** (with > fail-closed gating), the **entitlement gate**, and the **host admin** surface — every > mutation of which takes a platform actor and writes an append-only **audit log**. Since > this page was first written the [router](https://substrat.net/platform/router.md) and custom-domain issuance, > the ops [console](https://substrat.net/platform/console.md), the builder [dashboard](https://substrat.net/platform/dashboard.md), > [per-PR previews](https://substrat.net/guide/environments-and-previews.md), directory backup/restore, and the > platform-owned outbound seams (connections, egress policy, email) have all landed. What is > still designed-and-unbuilt is called out where it appears. ## One platform, N deployments Each vertical gets **its own kernel-runtime deployment**, hosting that vertical's scopes. Different verticals are separate deployments with separate storage namespaces. It's tempting to read that as *every vertical re-erects the whole platform*. It doesn't — and the split between what's shared and what isn't is the design: | | Shared — the platform owns it | Per-vertical | |---|---|---| | **Routing** | Resolves `hostname → (tenant, scope, vertical, surface)`, then dispatches | The surfaces a manifest declares | | **Custom domains** | Hostname issuance, DNS validation, certificate lifecycle | — | | **Tenancy** | Tenant registry, scope directory, provisioning lifecycle | — | | **Identity** | Auth callbacks, principal derivation, capability minting | — | | **Entitlements** | The store and the module-load gate | The `entitlementKey` each manifest declares | | **History & analytics** | The event spine and its history tier | — | | **Copies** | Snapshots, forks, per-PR previews, per-scope PITR, directory backup/restore | — | | **The outside world** | Connections and their sealed credentials, the outbound egress seam, the email relay | The declared allowlist and the connectors a manifest requires | | **Admin** | Directory, audit log, access log, ops-failure log; the [console](https://substrat.net/platform/console.md) and the builder [dashboard](https://substrat.net/platform/dashboard.md) | — | | **Execution** | — | **The scope's code**: kernel + engines + your modules, and their migrations | Everything a vertical would hate to rebuild is already shared. The only per-vertical thing is *the code that runs inside a scope*. > **The scope's code is the app binary; the platform beneath it is one platform.** So building a second vertical does not mean standing up a second Substrat. It means shipping a second binary onto the same substrate — new modules, new vocabulary, new screens, against the same tenancy, the same permission model, the same event spine, the same domains machinery. ## Why the deployments don't merge The obvious follow-up: if the platform is shared anyway, why not run every vertical's modules in **one** deployment and let entitlements decide which are active per scope? It's a coherent design — it's how ordinary multi-tenant SaaS works — and it is rejected, for one reason that dominates the others: - **Migrations would be globally ordered across unrelated verticals.** Every scope would carry every vertical's modules, and module registration order is a [migration-ordering contract](https://substrat.net/concepts/modules.md). A change to *another* vertical's migration list would touch *your* scopes. - **Blast radius would merge.** A bad deploy of someone else's module code would take down your scopes. - **Upgrades would go lockstep.** A shared binary means every vertical upgrades together — so you could not stay on engine v2 while another vertical moves to v3. Verticals on Substrat are often owned by *different companies*. Forcing one to upgrade because another shipped is the forced-upgrade treadmill that has degraded every extensible business platform that tried it. The shared-bundle alternative also trades a **structural** guarantee for a **configuration** guarantee — isolation would hold because the entitlement config says so, rather than because nothing is addressable. Substrat consistently refuses that trade: vertical code never gets a raw handle to the storage namespace, it gets a [capability stub](https://substrat.net/concepts/scope-host.md) for one scope, so cross-scope access isn't *denied* — it's *unreachable*. ## Scope lifecycle `provisioning → active → suspended ⇄ active → archiving → archived` Provisioning is idempotent and journaled, safe to re-run and safe to drive from a reconciliation sweep ([Tenants & scopes](https://substrat.net/concepts/tenancy.md)). Provisioning requires an existing **active tenant** — a scope can never be orphaned. The rest of the lifecycle is control-plane work; every transition is validated (an illegal one fails closed) and audited: - **Suspend** fails `getScope` closed for every scope under the tenant. It's how an incident or a non-payment is contained without deleting anything — the same fail-closed path that stops a confused-deputy bug. - **Archive** exports the scope's storage and releases it, keeping the registry row and the event history forever. **Un-archive is a restore, not a flag flip.** - **Jurisdiction is immutable.** Fixed at provisioning; a scope's execution domain can never relocate. There is no edit affordance because there is no edit. ## Asking the platform for something: intents, not calls {#platform-intents} A vertical sometimes needs a privileged action it structurally cannot perform: provision a sibling scope (a new Manyfold "site"), send mail, reach a third party. The obvious design is an upward call — the vertical holds a platform credential and invokes the control plane. It is rejected for the same reason the shared bundle above is: a credential in vertical code is a configuration guarantee, and the whole point is that the reach should not exist. Instead the vertical **enqueues an intent** into its own scope, and the platform comes and gets it: ```ts // inside an operation, AFTER the vertical's own permission check const id = ctx.requestPlatform({ kind: 'provision-sibling', payload: { slug, name, owner: principal }, }); ``` - The row lands in this scope's `_substrat_platform_requests` spine table, **atomic with the operation** — if the handler throws, the intent never happened. - Origin fields (`id`, `requestedAt`, `requestedBy`) are stamped kernel-side, exactly as an event envelope is. The vertical cannot claim to be someone else. - The platform's drain reads that scope's DO, so it **knows the tenant inherently** — the tenant is not in the payload and cannot be forged into it. - `kind` selects the platform-side handler, which validates the payload. To the kernel the payload is opaque, like an event's. - A scope may hold at most `MAX_PENDING_PLATFORM_REQUESTS` (32) pending intents; past that `requestPlatform` throws. A stuck or runaway vertical cannot flood the drain. Authorization is the vertical's; isolation is the platform's. That split is why the intent is enqueued *after* the vertical's own `ctx.check`. **And the outcome comes back.** `ctx.platformRequests(filter)` reads this scope's own intent journal — newest first, filterable by `kind` and `status` — returning the same record the platform settled, with its `result` or `lastError`. That read exists for one concrete reason: a contract whose signature request settled `failed` can say so on its own screen, instead of showing a document that appears to be out for signature and is not. The kernel owns every write to the table, so a status is only ever the platform's answer. ## Scheduled work {#scheduled-work} The platform runs one recurring pass — the **platform sweep** — that does every unit of scheduled work the system has, in a fixed order: reconciling stragglers' migrations, draining retryable effects and pending platform intents, re-running the provision hook where a scope's bound version has moved past the one it was provisioned against, running each vertical's declared [`schedules`](https://substrat.net/concepts/modules.md#recurring-work-schedules), judging its declared freshness expectations, reaping expired previews and long-archived scopes and lapsed tenants, reconciling connectors, draining each scope's domain events to Tier 2 through the injected `EventSink` — absent a sink, nothing drains — and shipping the staff access log last. The full list with what each phase skips is on the [`platform-sweep.ts` row](https://substrat.net/reference/kernel.md). Three of those phases leave a durable per-unit record that the console and `/sweep-runs` read — each live connection swept, each schedule run, each freshness verdict — when a deployment configures the recorder; the rest report through the pass's own summary. It is *the scheduler's unit of work*; it holds no timer of its own. A deployment drives it — a node server calls `startPlatformSweeper` at boot, a Cloudflare deployment arms a singleton `PlatformSweeperDO` alarm (a dispatch namespace doesn't honour `wrangler` crons, so an alarm is the timer such a deployment can own). Because module operations run in the vertical's own runtime — where its code and its scopes' data live — the sweep runs **there**, not in the control plane (whose scope storage is empty by design). For each vertical that declares schedules, the pass enumerates its live scopes and invokes each due operation under a system actor, recording per-scope outcomes and stepping over any failure rather than letting it sink the pass. A schedule fires no more often than its cadence, tracked per scope; a fork or snapshot is skipped, so a test copy never runs real recurring side effects. ## Entitlements gate modules, not features Every [manifest](https://substrat.net/concepts/modules.md) declares an `entitlementKey`. The platform holds a set of entitlements per tenant, and checks it **when a scope invokes an operation** — default-deny, uncached today (a DO-cached variant is an open benchmark). A module whose key the tenant does not hold **does not register** — its operations simply do not resolve, exactly as if the module had never existed. This is the same mechanism as a manifest `withdraws` declaration, and it is deliberately blunter than a feature flag: there is no half-loaded engine, no operation that exists but refuses, no code path where an unlicensed invariant is half-enforced. A tenant either has the work-order engine or does not have it. That bluntness is what makes the *load gate* safe to enforce at the boundary rather than sprinkled through business logic. **The flag also carries a plan.** Since #33 an entitlement is not only an on/off SKU: it carries a `plan` — `quota`, `expiresAt`, and a `tier` — that a vertical reads at request time through `ctx.entitlement(key)` / `ctx.entitlements()` to gate features and enforce quota *within* a module it already holds. So entitlements are the load gate **and** a feature/plan surface — the earlier "not a feature flag" line was too absolute. The kernel enforces two things itself: presence (the load gate above) and **expiry**, which fails closed at the gate exactly as a revoke would; `quota` and `tier` are expression only — the vertical reads the number and enforces its own meaning. For a **hosted** vertical these are read from a **scope-local projection**, so gating a feature needs no control-plane binding. It is the same mechanism as the load gate and, like it, **uncached today** (a DO-cached variant is the open benchmark). ## Why the admin console isn't a vertical Substrat's admin surface — provisioning scopes, suspending tenants, granting entitlements — runs **outside** the module system, and this is worth understanding because it's a good test of whether the isolation story is real. A super-admin, by definition, acts across every tenant. So: could you just build it as a vertical, with the platform as its own tenant? **No — and not because it would be dangerous. Because it would be inert.** A vertical's code reaches data through exactly one path: a capability stub for one `(tenant, scope)` pair that the kernel minted for it. It holds no handle to the storage namespace and no way to name a scope it wasn't invoked for — including, in production, scopes belonging to *other verticals' deployments*, which it cannot address at all. An admin vertical would sit there with no way to reach the thing it exists to manage. To give it that reach you'd have to build a privileged out-of-band path — which is the control plane, arrived at by a longer road. This is the isolation guarantee doing its job on its own author. The interesting half: *record-keeping* about tenants — who they are, which plan, what staff did — is ordinary scope-shaped data, and can perfectly well be a vertical on Substrat like any other. It's *acting* across tenants that cannot be. ## What this means for you - **Don't build tenancy, domains, audit, or identity.** They're below you. Verticals that rebuild them are a smell that the kernel drew a line wrong. Authentication in particular is a swappable edge adapter — see [authentication & identity](https://substrat.net/concepts/identity.md). - **Your scopes carry your code only.** Your migrations are ordered against your modules and the engines you depend on — never against a stranger's vertical. - **You upgrade on your schedule.** Per-vertical deployments exist so that engine versions, migrations, and blast radius stay yours. - **Declare an `entitlementKey` and mean it.** It is the unit at which your module is sold, licensed, and switched off. See [Tenants & scopes](https://substrat.net/concepts/tenancy.md) for the entities and addressing model, and [Modules & the manifest](https://substrat.net/concepts/modules.md) for what a deployment is made of. --- # Operations & the scope host Source: https://substrat.net/concepts/scope-host.md The scope-host contract is the heart of the kernel: **module code registers operations; callers reach a scope only through a capability stub.** The operation handler runs *inside* the scope's execution domain, which is what makes invariants enforceable — the handler sees `sql`, `emit`, `check`, and `link`; the caller sees only `invoke()`. ## The contract ```ts interface ScopeHost { getScope(principal: PrincipalId, tenantId: TenantId, scopeId: ScopeId): Promise; provisionScope(actor: PlatformActorId, input: ProvisionScopeInput): Promise; registerModule(registration: ModuleRegistration): void; // Out-of-band effects a module asks for but cannot perform — see // /concepts/events#the-connector-seam registerExecutor(id: string, eventType: string, handler: ExecutorHandler): void; defineOperation(name: string, handler: OperationHandler): void; readonly admin: HostAdmin; // control plane: roles/grants, tenant registry, // scope lifecycle, entitlements, audit log close(): Promise; } interface ScopeStub { readonly tenantId: TenantId; readonly scopeId: ScopeId; invoke(operation: string, input?: I, options?: InvokeOptions): Promise; } ``` Operation names are module-namespaced: `'workorder/create'`, `'invoicing/export'`. This is a teaching subset. The live `ScopeHost` has since grown a per-tenant relational store (`provisionTenantStore` / `openTenantStore`), scope import/restore/snapshot (`importScope`, `restoreScope`, `snapshotScope`), connector methods, and a scheduler seam (`getSystemScope` opens a stub whose authority is a module on a timer, and `registeredSchedules` / `runDueSchedules` let the [platform sweep](https://substrat.net/concepts/platform.md#scheduled-work) run a vertical's [recurring work](https://substrat.net/concepts/modules.md#recurring-work-schedules)) — surfaces the rest of these docs introduce where they belong. `getSystemScope(moduleId, tenantId, scopeId)` is the mirror of `getConnectorScope`: a door for a non-human caller. Where a connection's stub stamps `{ connection }` on its events, a system stub stamps `{ system: moduleId }` and checks against `system:` grants — so a scheduled operation is attributable to the schedule, and `ctx.check` stays its one gate. ### Preconditions travel per invocation, not in the input `invoke`'s third argument carries facts about the **request**, never about the domain. A handler's declared input is what the operation *means*; threading a retry token or an entity tag through it would make every in-process caller state something it does not have. `mountOperations` reads these off headers — a test, a seed or a schedule omits them entirely. ```ts interface InvokeOptions { readonly ifMatch?: string; // the caller's `If-Match` readonly onEntityVersion?: (version: string | null) => void; // the `ETag` to hand back readonly idempotencyKey?: string; // the caller's `Idempotency-Key` readonly onIdempotentReplay?: () => void; // sets `Idempotency-Replayed` } ``` One bag rather than a parameter per concern, because the two are **one precondition pass at one point in the invoke** — before the guards, inside the transaction. - **`ifMatch`** is the version the caller believes it is writing over, verbatim from the header (quoted, and possibly a list). **`onEntityVersion`** is called after a guarded operation *commits*, with the entity's version as the caller's own write left it — read after the handler and inside the same transaction, because a client that echoed back the tag it sent would loop on its own stale value. Neither runs for an operation that declares no `concurrency`, and neither runs for one that rolled back. - **`idempotencyKey`** has nothing to declare: it is honoured on every unsafe operation, since a retried write creating a second entity is a hazard on all of them. A first request under a key runs and its return value is recorded inside the operation's own transaction; a second under the same key **and the same input** returns that recording without running the handler, and **`onIdempotentReplay`** fires when it does. A key names one request, so the same key with a different input is refused rather than answered — serving the first request's response to a second one is a lie the caller would act on. **An option the operation cannot honour is refused, not ignored** — and refused before the invocation takes its turn, in both directions: - `ifMatch` sent to an operation that declares no `concurrency`. Nothing would have been compared, and a caller who believes its write is protected while it is not is the single failure this mechanism exists to prevent. Silence is how that belief survives. - `idempotencyKey` sent to an operation that declared `idempotency: false`. Its response is deliberately never recorded, so the retry would do the work a second time — while the caller reads its `200` as proof that retrying was safe. The HTTP half of both — the headers, the `412`, the replay window — is [§7 and §7b of API design](https://substrat.net/concepts/api-design.md#_7-writes-are-safe-to-retry). ## What a handler sees ```ts interface OperationContext { readonly tenantId: TenantId; // ambient — from the stub, not from the caller readonly scopeId: ScopeId; readonly principal: PrincipalId; readonly sql: ScopedSql; // synchronous, scope-local SQL now(): Instant; // the operation's instant — the only clock emit(event: DomainEventInput): void; check(permission: PermissionKey, entity?: EntityRef): Promise; search(entityType: string, term: string, options?: SearchOptions): SearchHit[]; page(entityType: string, params: PageParams): Page | CountedPage; versionOf(entity: EntityRef): EntityVersion | null; entitlement(key: string): Promise; entitlements(): Promise; link(child: EntityRef, parent: EntityRef): void; grant(principal: PrincipalId, permission: PermissionKey, entity: EntityRef): Promise; revoke(principal: PrincipalId, permission: PermissionKey, entity: EntityRef): Promise; requestPlatform(request: PlatformRequestInput): PlatformRequestId; platformRequests(filter?: PlatformRequestFilter): PlatformRequest[]; sealToConnection(provider: string, plaintext: string): Promise; atomic(fn: () => T | Promise): Promise; } ``` - **`sql`** queries the scope's own database — synchronously, because the data is local to the execution domain. One network hop to reach the scope, then local queries. - **`now`** is the operation's instant, and the **only** clock module code may read — `new Date()` and `Date.now()` are `boundary-lint` R6 violations, the same class of ban as `node:*`. It is **stable for the whole invocation**: every call returns the same value, so two rows written in one transaction cannot disagree about when they were written, and an event carries the same instant as the row it describes. The host injects it (`clock` on the host options), which is what makes elapsed time assertable — see [Testing with a clock](#testing-with-a-clock). - **`emit`** validates the event input and stamps the envelope kernel-side (id, timestamp, tenant, scope, actor). See [Events & audit](https://substrat.net/concepts/events.md). - **`check`** asks the permission checker about the ambient principal at the ambient node, optionally narrowed to one entity. See [Permissions](https://substrat.net/concepts/permissions.md). - **`search`** returns entity ids from the FTS index the kernel derived from the manifest's `searchables` — a module never writes a `MATCH` of its own. An entity type nobody declared searchable throws `NotSearchable` rather than returning nothing. - **`page`** reads one page of a declared entity: the kernel composes the `WHERE` from the operation's declared `filterable` columns, the `ORDER BY` from the caller's choice among `sortable`, the keyset cursor, the `LIMIT`, and — when the declaration asks for one — the total over the same `WHERE`. It reads the indexes it also provisioned. Rows come back wrapped; `mapPage` re-shapes the entries and keeps the cursor. An undeclared sort or filter throws (`SortNotDeclared`, `FilterNotDeclared`), never silently applies nothing. See [Lists are pages, not dumps](https://substrat.net/concepts/api-design.md#_4-lists-are-pages-not-dumps). - **`versionOf`** is an entity's version — the ULID of the last event about it, read from the outbox; there is no version column. It is what a read-modify-write's `If-Match` is checked against, and it survives a shred, so an erased entity can still refuse a stale write. The caller's side of that comparison is [`InvokeOptions.ifMatch`](#preconditions-travel-per-invocation-not-in-the-input); the transport's side is [A read-modify-write says what it is writing over](https://substrat.net/concepts/api-design.md#_7b-a-read-modify-write-says-what-it-is-writing-over). - **`entitlement`** / **`entitlements`** read the tenant's currently-held entitlements at request time — the sanctioned way a hosted vertical gates a feature or enforces its own quota *without* a control-plane binding. `entitlement(key)` returns the live `EntitlementView` (`key`, `plan`, `quota`, `expiresAt`) or `null` when the tenant does not hold the key; expiry is applied at read, so a non-null result is always live. The kernel enforces presence + expiry; the vertical decides what `quota` means. On a hosted vertical this reads a scope-local projection. See [The platform layer](https://substrat.net/concepts/platform.md#entitlements-gate-modules-not-features). - **`link`** records a child→parent relation tuple (e.g. work order → facility) used by the permission evaluator's entity-edge rule. The relation must be declared in a registered module's `entityRelations`. Idempotent. - **`grant`** / **`revoke`** narrow a permission the caller **already holds** onto one entity — how an app expresses user-initiated sharing. Non-escalating by construction: `entity` is required, so module code can never write a scope- or tenant-wide grant, and the caller's own decision on that entity is re-checked first. **Delegation, never elevation.** Transactional with the operation, like its rows and its events. Without it, an app where a person shares their own record would need a membership table consulted by hand in every handler — the forgotten-`WHERE`-clause failure this platform exists to remove. - **`requestPlatform`** / **`platformRequests`** enqueue a durable [platform intent](https://substrat.net/concepts/platform.md#platform-intents) and read back what the platform did with it — the sandbox-clean way to ask for a privileged action, with no upward call and no credential in vertical code. - **`sealToConnection`** seals a value to a *connection's* public key so it can ride on an event that a connector opens at egress. The scope never holds the private half; the consumer still needs no cross-module read, it just cannot read that one field. It takes a provider name (`'scrive'`), never a connection id — connection identity is the host's business, and an engine that learned it would be naming infrastructure it is not allowed to see. It **fails closed and legibly**: no projected key for that provider throws, rather than emitting a request that silently reaches nobody. Await it *before* `ctx.emit`, which is what lets `emit` stay synchronous. - **`atomic`** runs a callback as a sub-transaction inside the operation's own. A throw inside discards everything the callback wrote — rows, events, links, grants, platform intents — while the operation's other writes survive, and it still commits once. This is the **only** place module code may catch an engine error: an engine call composed inside your transaction has no boundary of its own, so a bare `catch` would commit its partial writes. `boundary-lint` R7 rejects the unprotected form. A succeeded `atomic` is still provisional — if the operation later throws, its writes go too — and sub-transactions nest but must not interleave. ## Testing with a clock Anything whose behaviour is a function of *elapsed* time — an absence request going stale, a metering period rolling, a cart hold lapsing — is untestable against the wall clock: the interesting branch never runs inside a 40 ms test. The usual workarounds are to sleep (slow and flaky) or to shrink the window to zero, which proves that an already-expired thing is expired and nothing about expiry. The host takes a `clock`, so a suite can move time on purpose: ```ts import { manualClock } from '@substrat-run/kernel'; const clock = manualClock('2026-03-02T09:00:00.000Z'); const host = new SqliteScopeHost({ dir, clock: clock.read }); await guest.invoke('shop/add-to-cart', { cartId, variantId, qty: 1 }); clock.advance(14 * 60_000); // inside the 15-minute hold — still reserved await expect(otto.invoke('shop/add-to-cart', { … })).rejects.toThrow(/out of stock/); clock.advance(2 * 60_000); // past it — the unit is sellable again await otto.invoke('shop/add-to-cart', { … }); ``` No real time passes. `frozenClock(at)` is the simpler form when a test only needs one fixed instant. Both come from `@substrat-run/kernel`, so a vertical's own suite gets them without depending on our test tooling. **Timestamps are stored as ISO 8601 text.** `ctx.now()` returns an `Instant` — an ISO string with an offset — and that is what goes in the column (`created_at TEXT NOT NULL`). Text sorts and compares correctly, reads correctly in a `.sqlite` file someone opens by hand, and needs no per-table convention about seconds versus milliseconds. Epoch integers appear in this repo only inside a third-party schema that defines its own storage (Better Auth's tables in `demos/auth-server`), which is that library's contract, not ours. ## Contract semantics — what every adapter guarantees These are the semantics the [conformance suite](https://substrat.net/reference/contract-tests.md) verifies, so you can rely on them regardless of which adapter is underneath: ### Strict serialization per scope One operation at a time, to completion. Ten concurrent read-await-write increments land on exactly ten. Module code never needs locks, transactions-for-concurrency, or retry loops against its own scope. ### Structured-clone boundary Inputs and results are cloned on every stub call, both directions — even in-process. Mutating an input object after `invoke()`, or mutating a returned result, can never affect scope state. Code cannot share mutable state with a scope, so "it worked locally because we shared memory" bugs are impossible by construction. ### Fail-closed addressing `getScope` validates `(tenantId, scopeId)` against the directory. A mismatched pair throws; it never resolves to another tenant's scope. The same fail-closed path also gates lifecycle status: a suspended or deleting **tenant**, or a suspended or archived **scope**, fails `getScope` — which is how suspend and archive actually contain. Operations are gated once more at `invoke`: a module whose `entitlementKey` the tenant does not hold does not resolve. ### Kernel-stamped events The event envelope's origin fields are not parameters. See [Events & audit](https://substrat.net/concepts/events.md). ## In-scope functions vs registered operations Engines expose their logic at two altitudes: - **Registered operations** (`'workorder/create'`) — the default bindings, each starting with its own permission check. Invoke these through a stub. - **In-scope functions** (plain exports like `createWorkOrder(ctx, input)`) — composable building blocks a *vertical's own operation* can call in the same transaction, when it needs to wrap engine behavior with domain logic (pricing, extra validation). The caller is then responsible for the permission check. This is how a vertical customizes without forking: write your own operation, call the engine's in-scope functions, keep everything inside one serialized, audited execution. ```ts host.defineOperation('acme/create-priced-workorder', async (ctx, input) => { assertAllowed(await ctx.check(PERM.create)); const order = createWorkOrder(ctx, toEngineInput(input)); // engine function ctx.sql.exec('INSERT INTO acme_pricing ...'); // vertical's own table return order; }); ``` ## Event consumers A module can subscribe to event types (declared in its manifest under `events.consumes`). Consumers run as ordinary in-scope operations under a **system actor**, with at-least-once delivery tracked in a kernel delivery journal — so handlers must be **idempotent**. Ordering is guaranteed only within one (scope, module) pair. The [invoicing engine](https://substrat.net/engines/invoicing/index.md) is the reference example: it consumes `workorder.completed` and rebuilds its own state from the event payload alone. --- # Permissions Source: https://substrat.net/concepts/permissions.md The permission **model** is kernel-owned — it is enforcement input, never delegated to an auth provider. The **evaluation engine** is an adapter behind one interface, with a built-in relationship-tuple engine as the default. ## The authored surface Humans (and agents, under review) write three kinds of things: ### Permission keys Module-namespaced strings declared in the [manifest](https://substrat.net/concepts/modules.md) with human-readable descriptions: ``` workorder:create Create work orders workorder:report Start work, report time and material invoicing:export Export an invoice basis (makes it immutable) ``` ### Roles @ nodes A role bundles permissions; an assignment binds a principal to a role **at a node of the tenancy tree** — the tenant root or a specific scope — with inheritance down the tree: ```ts host.admin.defineRole(tenantId, { key: 'technician', permissions: ['workorder:read', 'workorder:report'], source: 'vertical', }); host.admin.assignRole({ principalId: tech, roleKey: 'technician', node: { tenantId, scopeId: stockholmBranch }, // or scopeId: null = whole tenant }); ``` ### Capability grants Narrow, direct, time-boxable grants — one permission, one node, optionally **narrowed to one entity** and its declared descendants: ```ts host.admin.grant({ principalId: portalCustomer, permission: 'workorder:read', node: { tenantId, scopeId: branch }, entity: { entityType: 'facility', entityId: theirBuilding }, expiresAt: nextMonth, // optional grantedBy: adminPrincipal, }); ``` Entity-narrowed grants are how portal users (a customer, a board member, a subcontractor) see only *their* facilities and orders inside a shared scope. Grants can also target an **organization**; members reach them via membership. A **connection** is a first-class grant subject too — not only principals and orgs. A connector (an external provider acting through a connection) can hold a capability grant without pretending to be a person: `host.admin.grantToConnection(...)`, and a check's subject is polymorphic (`{ kind: 'principal' | 'connection' | 'system', id }`). A connection is keyed `(tenant, vertical, provider)`, so granting it one permission reaches only that tenant's scopes running that vertical — the blast radius of a leaked provider token is one permission on one vertical's data, readable in a diff. A **module's system principal** is the third such subject — the caller behind [scheduled work](https://substrat.net/concepts/modules.md#recurring-work-schedules). A schedule runs an operation on a timer, and it is not a person either; `host.admin.grantToSystem(...)` gives `{ kind: 'system', id: moduleId }` exactly the permissions the schedule declared, so the operation's own `ctx.check` resolves the same way it does for anyone — the gate stays the check, never a bypass — and the emitted events read as `{ system: '@your/module' }`. Like a connection it holds no memberships; its authority is exactly the grants written against `system:`, projected at provisioning and revocable per scope. Organizations are a real directory record, not a string you make up at the call site: ```ts await host.admin.createOrg(actor, { id: orgId, // branded ULID — slug and name are attributes, not identity tenantId, slug: 'acme', // unique within the tenant name: 'Acme AB', }); ``` `addMember`, `removeMember`, `listMembers` and `grantToOrg` all **fail closed on an org that does not exist in that tenant**. That refusal is the point of the record: a grant to an org nobody registered would otherwise look applied, resolve for nobody, and still show up in the permission diff as though access had been conferred. Because the id is a ULID rather than a name, renaming an org cannot orphan the tuples that reference it. ## From declaration to enforcement The **keys and role templates** are declared in TypeScript — the same objects the host registers ([module manifests](https://substrat.net/concepts/modules.md) declare the keys, provisioning declares the roles). That declared surface is not trusted to stay honest by convention; it passes three successive gates on its way to a running scope: **Diagram — a permission, from declaration to check.** 1. **Declared in TypeScript** (authored) — module manifests → keys + descriptions; roles → templates · entity grants → shapes. - Branches off to **pnpm lint:permissions** (checkpoint · a human reads this), which renders emits PERMISSIONS.md, the review artifact; CI --check fails the build on drift. This branch ends in a human review, not at runtime. 2. *substrat push* → **The deploy manifest** (ships) — the surface rides along as a registry; content-hashed → digests.permission. 3. *promote* → **Admission** (gate) — a real diff between two versions; a widened surface is visible, not implicit. 4. *at write time* → **Provisioning** (per tenant) — role templates projected into each; tenant's own _substrat_roles. 5. *every operation* → **ctx.check** (runtime) — reads scope-local tables only; absent or empty projection = deny. The branch off the top ends in a person: PERMISSIONS.md exists to be read in a diff, and CI going red is what makes the reading unskippable — it is not itself the approval. Three kinds of honesty, at three altitudes: - **Review-time.** `PERMISSIONS.md` is the human-readable render of the surface — the artifact for the [permission checkpoint](https://substrat.net/concepts/modules.md). Because CI regenerates it with `--check` and fails on any difference, a role that gains a key cannot merge without showing up in the diff a reviewer must approve. The check reads the *same* declared objects the host registers, so the artifact cannot drift from what is enforced. - **Deploy-time.** The surface is carried in the version's deploy manifest and content-hashed as `digests.permission`, so **admission** can compute a genuine permission diff between the running version and the incoming one. The declared surface is **immutable per version** — it is a property of the code, frozen when that version is built. - **Request-time.** Roles and grants are *projected* into each scope's own tables at write time; the checker reads only that local projection (see below), and an absent or empty projection is a **deny**. Missing data can only ever remove authority, never confer it. This is the same layering the rest of the model rests on: the **declared** surface is a per-version code fact, **operator** state (a tenant's live roles) is a runtime-mutable table, and **minted** grants are scope-local tuples. None is a copy that can silently diverge from the others — each is the authority for its own layer. ## Evaluation: relationship tuples with a fixed algebra Internally, the built-in checker compiles the authored surface into relationship tuples (`subject → relation → object`) and evaluates checks with a **fixed, four-rule derivation algebra**: 1. **Role expansion** — principal has role, role carries permission. 2. **Tenancy-tree inheritance** — permission at a node flows down to child scopes. 3. **Entity parent edges** — declared in module manifests (`workorder → facility`) and written at runtime via `ctx.link`; entity-narrowed grants flow along these edges, depth-capped. 4. **Org/group membership** — grants to an organization reach its members. No negation, no configurable rewrite rules. Verticals never see or author tuples — roles and grants remain the only authored surface. **Where tuples live — a scope reads only its own state.** Scope and entity tuples (rules 2 and 3) live in the scope's own database and are evaluated inside its serialization domain, so there is no distributed-consistency problem for them. Tenant-level facts — role *definitions*, role assignments, tenant grants, and org membership (rules 1 and 4) — are tenant-wide, so the **directory** (the control plane) is their authority. But the checker never reads the directory on a request: the control plane **projects** those facts into scope-local tables (`_substrat_roles`, `_substrat_tenant_tuples`) at *write* time, and the checker reads the projection exactly as it reads scope tuples. This is the important shift, and it is deliberate: **the control plane is a write-time authority that projects into scopes; it is never a read-time dependency on the request path.** Cost moves from the read path (every request) to the write path (rare role/grant changes) — the correct direction for a read-heavy multi-tenant system, and what makes a scope genuinely isolated: it can run as its own Cloudflare Worker with *no* control-plane binding at all. (For the earlier per-request model and why it was replaced — scaling, isolation, and the untrusted-vertical requirement — see the [scope-local permissions design note](https://github.com/substrat-run/substrat/blob/main/docs/architecture/scope-local-permissions.md).) The consistency consequence is small and one-directional. Scope-level grants/roles stay synchronous and immediately consistent ("no zookies") — the checker runs in the scope's own serialization domain. Only **tenant-level** changes are now eventually consistent across a tenant's scopes, bounded by the write-time fan-out and a reconciliation sweep. An **absent or empty projection is a deny**, byte-for-byte the normal deny path, so missing data can only ever *remove* authority, never grant it. Revocation still fans out as tombstones (below) — now into every scope the tuple was projected to. ## Revocation: tuples tombstone, they never disappear Access is withdrawn by **tombstoning** a tuple — it keeps its row, gains a `revokedAt`, and the checker's walk skips it. Nothing deletes a tuple. That is deliberate. A tuple that once granted access is the evidence of *why* an access was allowed, so deleting it destroys the audit trail exactly where it is most needed: a deleted row can show neither that access was revoked nor that it was ever granted. Every revocation path in the kernel works this way — `removeMember` is the first of them — and `listMembers({ includeRevoked: true })` is the evidence view over the result. Liveness is therefore one predicate, applied identically everywhere: a tuple grants only while it is **unexpired and unrevoked**. Expiry (`expiresAt` above) and revocation are siblings, not separate mechanisms. The checker interface is deliberately swappable (an OpenFGA-backed adapter is the designated alternative), and any implementation must pass the same contract tests. ## Decisions carry proof ```ts type Decision = | { allowed: true; proof: RelationTuple[] } // the chain that granted access | { allowed: false; checked: PermissionKey; node: Node }; ``` An allow **always** carries the tuple chain that produced it — an unexplained allow is unrepresentable. This powers: - **explain** — why does this user see this? - **view-as-user** — render any screen as any principal, with real decisions; - **the human-readable permission diff** — the review artifact for the permission checkpoint: who gains what, where in the tree. And it carries past the request: when an operation emits, the kernel stamps onto the event envelope which permission(s) it checked-and-passed and — when the allow came through a capability grant rather than a role — a ref to the granting tuple (`authorization`, K-34). The full chain is re-derivable by `explain`; what re-derivation cannot recover once tuples have since changed is *which* permission and grant were consulted at write time. That pointer is what the envelope keeps, so a mutation carries its own proof of authority ([Events & audit](https://substrat.net/concepts/events.md)). Decisions carry proof forward, not only at the instant they are made. ## Denials are recorded An allow leaves its trace on the event it authorized; a **denial** has no event to ride, so it is captured on its own. When an enforced check refuses, the kernel writes a row to a scope-local `_substrat_denials` table (`id`, `actor`, `permission`, `tenant_id`, `scope_id`, `operation`, `impersonation`, `at`, `drained_at`) — the one moment where an actor's intent and the permission model visibly disagree, and which no other log witnesses (the admin log records changes, the outbox records *allowed* mutations). Because a denial rolls its operation back, the row is written **outside** that transaction, at the operation boundary after the rollback, so the record survives the very failure it describes. `impersonation` is the same `{ session, by }` stamp the event envelope carries, and null for the same reason — see [Impersonation](https://substrat.net/concepts/events.md#impersonation): `actor` is the principal the check answered about, and this is the staff member who was acting as them, when one was. This is **not** the K-24 staff-directory read log (`_substrat_access_log`): that records control-plane *reads* against the directory; `_substrat_denials` records refused checks inside a scope. Two different tables for two different facts. Both *drain* rather than expire — `drained_at` marks a row shipped onward, and only a drained row may be pruned. ### Reading them back Two staff reads on `HostAdmin`, served by the control plane as `GET /tenants/:t/scopes/:s/denials` and `…/denials/summary`, and rendered per scope in the console: - **`summarizeDenials`** buckets the log per (actor, permission) with a count, the number of distinct operations, and first/last occurrence. This is the view to open first, and the ordering is **by count** on purpose: the volume of this log is attacker-influenceable — a probing client mints unlimited rows — so a newest-first page would let whoever wrote the last hundred rows push every other actor off the screen. `groupBy: 'operation'` asks the other question — which operation keeps getting refused — with one bucket per operation and the same `total` and window facts beside it; the summary echoes the grouping it answered with. A denial that unwound something other than an operation invocation has no operation, and those rows come back as one bucket keyed `operation: null` — still counted, still part of `total` — rather than vanishing from the sum. - **`listDenials`** returns the raw rows behind a bucket, newest first, narrowed by `actor`, `permission`, `operation`, and a `since`/`until` window. The summary also carries the log's own **window** — its oldest and newest held rows, computed *ignoring* the filter. That is what keeps an empty result honest: because rows drain rather than expire, absence before the window's floor means "no longer held", not "never happened". Both reads are actor-stamped and recorded to the K-24 access log, so reading the denial log is itself logged, and both cross-check the `(tenantId, scopeId)` pair and fail closed on a mismatch — a confused-deputy scope id resolves to nothing, never another tenant's refusals. ## In operations The standard first line of every operation: ```ts import { assertAllowed } from '@substrat-run/kernel'; const handler: OperationHandler = async (ctx, input) => { assertAllowed(await ctx.check('workorder:read', orderRef(input.orderId))); // ... }; ``` `ctx.check` evaluates the ambient principal at the ambient node. Pass an `EntityRef` for per-entity checks: the checker tries node-level first (staff see everything in the scope), then walks the declared parent edges against entity-narrowed grants (portal users see their own things). ## Sharing at runtime: `ctx.grant` and `ctx.revoke` Entity-narrowed grants are minted at seed time by a platform actor (`HostAdmin.grant`). When a **user** shares their own record with another person — and takes them off it again — the operation does it itself, on `OperationContext`: ```ts await ctx.grant(principal, PERM.listContribute, listRef(listId)); // share await ctx.revoke(principal, PERM.listContribute, listRef(listId)); // un-share ``` Three guardrails make this non-escalating by construction: - **Entity-required** — module code can never write a scope- or tenant-wide grant, only narrow one onto a thing. - **Delegating** — the caller's own decision on that entity is re-checked, so an operation can only hand out what it was itself given. Delegation, never elevation. - **Transactional** — a grant made by an operation that then throws never happened, the same as its rows and its events. Every later `ctx.check` reads the grant, so nothing else in the app has to remember who may touch what. Neither alternative is this: a `ctx.link` parent edge is **not revocable at all** — it is permanent — and org membership is revocable but coarse-grained (a whole org, not one record). The mistake this section exists to prevent is minting an org per domain row — or keeping a membership table consulted by hand in every handler — to get a revoke the kernel already has. The [todo demo](https://github.com/substrat-run/substrat/tree/main/demos/todo) (`src/module.ts`, `todo/share-list` and `todo/revoke-share`) is the two-line reference. ### The screen outlives the grant A revoke has a client-side half. The app follows three rules, and the first two are the ones every vertical here already keeps: it **never filters for access** — a list nobody shared is not hidden, it never comes back from the server at all — and it **renders what the server returned**, so a 403 is a wall with the server's own message, never an empty list. The third is the one a permission-centric app cannot skip: it **re-asks**. Someone sitting on a list they were just removed from keeps seeing it until something refetches. Nothing leaks — the server refuses every subsequent action — but they are looking at data they no longer have access to, and for an app whose pitch is "you see only what you may", that is the worst-looking failure available, and exactly what a revocation demo shows on stage. So a view revalidates on focus, on visibility and on a slow poll (`useAutoRefresh(load)` from `@substrat-run/ui`), a click on the nav item already selected refetches instead of being a no-op same-route link, and a read that comes back 403 **replaces** the content with the wall rather than leaving it underneath — every read the screen makes, the next page of a walk and the detail of one row as much as the refetch. `demos/todo/app/src/App.tsx` (`ListView`) and `demos/shop/app/src/App.tsx` (the nav tabs) are the reference. ## Assigning a role: `ctx.canAssign` Reviewing role *definitions* protects nothing on its own. Nothing in a checkpoint over `defineRole` stops an `admin` from assigning someone — or themselves — to `owner`: no role was widened, no `defineRole` was called, and no permission diff shows anything. The moment a vertical defines any role carrying role-assignment permission, assignment becomes an escalation path. So there is a bound, and it is a mechanism rather than a convention: > A principal may assign role `R` at node `N` only if the assigner already holds every > permission `R` carries at `N`. ```ts 'team/invite': async (ctx, input) => { assertAllowed(await ctx.check(MEMBER_MANAGE)); // may you manage members at all const bound = await ctx.canAssign(input.roleKey); // may you confer THIS much if (!bound.covered) { throw substratError('forbidden', `cannot assign ${input.roleKey}: you do not hold ${bound.missing.join(', ')}`); } … } ``` Two checks, in that order, answering different questions. `canAssign` is a bound, never a substitute for the operation's own permission check. **Removal takes the same bound.** A junior admin who can strip a role they could not have granted can lock the owner out of their own tenant. Revocation is the mirror of assignment, not a lesser act — so gate both sides on it. **It is narrowing-aware, and that is the load-bearing part.** Only authority held at the *node* counts. A principal whose `workorder:read` is narrowed to one entity does not thereby hold `workorder:read` for the purposes of this comparison — otherwise sharing a single record would launder into authority over every record, by way of assignment. Membership does expand: authority held through an org is authority that can be conferred. **One resolution, not N checks.** `ctx.check` answers about one permission and walks the tuples to do it, so asking it twenty times for a twenty-permission role walks them twenty times on every invite acceptance. The kernel resolves the effective set once and compares — which is also why the comparison lives in the kernel rather than in each vertical: enumerating "who can do what" is the kernel's job, and this is that enumeration turned inward. **Bootstrap.** The rule implies a tenant's first admin cannot assign themselves, and does not need to: the initial owner is seeded platform-side during provisioning, which is out-of-band host code that already holds the authority. Self-service begins at the second member. ## Defaults - `denyAllChecker` — the secure default. A host without an explicit checker allows nothing. - `UNSAFE_allowAllChecker` — grants everything to everyone via a synthetic proof tuple. For tests and scratch scripts; the name is deliberately alarming. Never wire it into anything a tenant can reach. --- # Authentication & identity Source: https://substrat.net/concepts/identity.md The kernel **authorizes**; it never **authenticates**. Every operation runs against an ambient `PrincipalId` that is already established by the time the kernel sees it — the kernel never learns *how* a caller proved who they are, only *who they are*. Authentication is a swappable adapter at the edge (decision D-16). ## The line: authenticate vs authorize - **Authorization is the kernel's** — roles, grants, tenancy, the tuple evaluator ([permissions](https://substrat.net/concepts/permissions.md)). This is enforced on every operation and cannot be delegated to an outside system. - **Authentication is an adapter's** — it takes a request (a session cookie, a bearer token, an OIDC `sub`) and resolves it to a `PrincipalId` + home node. Nothing more. - An external IdP's **organizations/roles are a projection** of kernel tuples, never the source of truth. If you let an IdP's RBAC decide access, you have two permission systems fighting — exactly what the three-layer rule forbids. ## The neutral seam The control-plane directory holds one provider-agnostic mapping: ```sql _substrat_identities ( provider TEXT, -- 'better-auth' | 'oidc:' | … external_id TEXT, -- the provider's stable user id (e.g. the OIDC `sub`) principal_id TEXT, -- the Substrat principal it resolves to tenant_id TEXT, scope_id TEXT, -- NULL = tenant-level home PRIMARY KEY (tenant_id, provider, external_id) ) ``` exposed on the host admin as two methods: ```ts linkIdentity(actor, { provider, externalId, principal, tenantId, scopeId? }): void // audited; idempotent per (tenantId, provider, externalId) resolveIdentity(tenantId, provider, externalId): { principal, scopeId } | undefined ``` Because the mapping is **keyed by provider**, several auth adapters — and several OIDC upstreams — coexist without collision. Adding one is additive: no schema change, no permission, no kernel change. **Pools are registered, and declare their topology.** Before a provider may link anything it registers as `central` (one pool serving many tenants — the same `externalId` everywhere is the same human) or `tenant-bound` (one pool, one tenant — the same `externalId` elsewhere is a *different* human). An unregistered provider is refused rather than defaulted: without that fact the kernel would be guessing whether two people are one. ```ts await host.admin.registerIdentityPool(actor, { provider: 'oidc:https://issuer.example', topology: 'tenant-bound', tenantId, // null for a central pool }); ``` Topology, not audience. A padel player on a branded multi-club platform is a *consumer* who wants the central topology, while a shopper on a white-label store is a consumer who must not have it — so the audience is shorthand and the topology is the enforceable fact. A provider string names exactly one pool, so separate per-tenant deployments take distinct provider strings (`oidc:`). **And keyed by tenant.** The full key is `(tenantId, provider, externalId)`, with the tenant as an *input* to the lookup rather than something derived from the identity. That matters as soon as tenants have their own identity pools: an external subject id is unique only *within* its pool, so two white-label shops can both issue user `123` to different people. A globally-keyed mapping would resolve the second as the first. The same key is what lets one login belong to several tenants — a consultant administering five customers is one external identity with one row per tenant, and one principal per tenant. Shared identity, separate authority. The caller always knows which tenant it is asking about: the request arrived on that tenant's hostname, or carried a pool-scoped token. **"Which tenants is this login in?"** is a separate call, and central-only: ```ts await host.admin.listIdentityTenants(actor, provider, externalId); // → TenantId[] ``` It throws on a tenant-bound pool. Not because the answer would leak — the caller already knows that pool's tenant — but because asking is a category error: where the same `externalId` names different people per tenant, a tenant list has no meaning. Enumerate, then resolve within the one you picked. When the caller wants the whole picture — a team switcher, or a per-request "who is this login, in the team they selected" — asking once per tenant is the wrong shape: on a hosted directory every one of those reads is a round trip, and the cost grows with the number of tenants the login is in. `listIdentityMemberships` is the same central-only question with the follow-ups already attached: ```ts await host.admin.listIdentityMemberships(actor, provider, externalId); // → [{ tenant, principal, scope: { id, vertical, status } | null }] ``` One read, one access-log row. Each entry carries the tenant row whole — `status` included, and a non-active tenant is returned rather than filtered, because whether a `deleting` team still resolves is the caller's policy — the login's principal in it, and the scope the link was made in. It throws on a tenant-bound or unregistered pool exactly as `listIdentityTenants` does. ## Auth adapters at the edge An auth adapter is anything that turns a request into a principal: ```ts interface AuthAdapter { resolve(headers: Headers): Promise; // null = "not mine" } ``` The server tries its mounted adapters in order; the first to recognise the request wins, and the resolved principal is handed to `getScope`. Which adapters a vertical mounts is the vertical's decision, and the two shapes in the repo make it differently: ```text # most verticals — one provider, picked in code authProviderFor(env) → oidcRpAuthProvider({ issuer, clientId, … }) # demos/shop — the same OIDC session, else an anonymous browse-only fallback: # a storefront must answer "what may someone who has not signed in see" oidcRpAuthProvider(…), then the public adapter, last ``` An OIDC-only vertical has no auth switch at all: the relying party from [`@substrat-run/vertical-auth`](https://substrat.net/reference/vertical-auth.md) is its *only* way to resolve a caller, and the thing that varies is `OIDC_ISSUER` — a configuration value, not a code path. **"Log in with …" belongs at the issuer, not in the vertical.** An issuer can federate upstream identity itself (Microsoft, Google, BankID, enterprise SSO — see [the issuers](#the-issuers-bring-your-own-or-use-ours) below), so those buttons become issuer configuration while the vertical keeps exactly one adapter against the *same* `resolveIdentity`. The kernel is untouched either way: doing the seam neutrally is what buys the choice. ### Two real choices, made differently The neutral seam is not hypothetical — the codebase exercises both ends of it deliberately: - **The platform's own apps** — the [Dashboard](https://substrat.net/platform/dashboard.md) and the [control-plane Console](https://substrat.net/platform/console.md) — authenticate against **AuthHero OIDC** (Authorization-Code + PKCE) through the shared [`@substrat-run/oidc-rp`](https://substrat.net/reference/oidc-rp.md) relying party. One IdP, one session model, across every platform surface — including the `substrat login` CLI, which brokers the same flow. This is the "log in with SSO" corner, chosen because platform staff and tenant admins are one identity population the platform runs itself. - **Every demo vertical is OIDC-only** — none runs a credential store. Login, sign-up, password and reset all live at an OIDC issuer, and the vertical does exactly one thing with the result: bind the authenticated `sub` to a scope principal — hosted, in its per-tenant `IdentityDO` through the owner-claim and invite flows ([`@substrat-run/vertical-auth`](https://substrat.net/reference/vertical-auth.md)); locally, through the host's identity directory, which the seed fills from the same persona list the issuer shows. Hosted, that issuer is whatever the tenant configured; locally it is the [dev issuer](#the-dev-issuer-local). The one deliberate wrinkle is the shop, which also keeps an **anonymous** browse principal — not a credential store, but the storefront's answer to "what may someone who has not signed in see". The design record is [`oidc-only-demos.md`](https://github.com/substrat-run/substrat/blob/main/docs/architecture/oidc-only-demos.md). That the same kernel serves all of them, unchanged, is the seam paying out. Better Auth still runs in the repo — in exactly one place, as an *issuer* rather than an adapter: the [auth server](#the-auth-server-a-full-oidc-provider-you-can-run) below. ## The issuers: bring your own, or use ours A vertical is a relying party, so **any OIDC server works** — Okta, Entra, Keycloak, AuthHero, your own. Pointing a deployment at it is a change of `OIDC_ISSUER` (or of the delivered `substrat:auth` connection), never a code change. Two issuers ship with the platform so you never have to bring one just to start: ### The dev issuer (local) [`@substrat-run/dev-issuer`](https://substrat.net/reference/dev-issuer.md) is what every demo's `pnpm dev` starts: a **real** OIDC provider — discovery, JWKS, Authorization Code + PKCE, a signed ID token — whose only shortcuts are that `/authorize` lists the vertical's personas instead of asking for a password, and that it registers no clients and checks no client secret (loopback-only, never deployed, so there is nothing to compare against). The protocol the vertical runs is unchanged: the dev login *is* the production round-trip, and a script impersonates someone at the issuer (`POST {issuer}/dev/token {"sub": …}`), never inside the vertical. ### The auth server (a full OIDC provider you can run) The [`auth-server`](https://github.com/substrat-run/substrat/tree/main/demos/auth-server) is a complete OIDC provider built on **Better Auth** — accounts, passwords, verification and reset mail, discovery, JWKS, the whole authorize/token/userinfo surface — deployable standalone on its own hostname or installed per tenant on the platform (each install is its own issuer with its own store and signing keys). It exists so "I just need somewhere for my users to log in" has a stock answer; it stops being special the moment you swap `OIDC_ISSUER` for an issuer you already run. What it ships: - **An admin dashboard.** Users (roles, bans), the relying-party registry — register an application, rotate its secret, disable it, including clients that registered themselves — sign-in options, and the issuer's own discovery details. The issuer is its own first relying party; the dashboard is gated by its `admin` role. - **Federated sign-in, enabled from the dashboard.** Microsoft (optionally pinned to one Entra directory), Google, GitHub and Supabase, as a closed catalogue: enabling one is a credential plus two decisions — may it create accounts, and is its email trusted for linking to existing ones — rather than a form of URLs to get subtly wrong. Supabase asks for one thing more, because no one could guess it: a project's issuer is the project URL with `/auth/v1` on the end, and the project URL alone serves no discovery document. (It also needs the project's own OAuth 2.1 server turned on, authorization UI included — work on the Supabase side that no setting here can stand in for.) One open door sits beside that catalogue: **Custom (OIDC)**, for the directory it does not name — Keycloak, Okta, Auth0, another auth server like this one. The ask keeps its shape. A custom provider is a slug (which *is* its callback path segment), a button label, the upstream's **issuer URL** and a credential, then the same two decisions — never five endpoints typed by hand, because an issuer URL is the one address OIDC lets everything else be derived from. Discovery is resolved once, when the provider is saved rather than on every sign-in, so an issuer URL that serves no discovery document is refused while the operator is still in the form. One issuer-wide setting sits above the per-provider ones: whether an upstream sign-in may **join** a local account that already holds the same email address, or must refuse and let the person connect the provider deliberately from inside a session. Keeping two separate accounts on one address is not offered — an email resolves to exactly one user in password sign-in, reset and recovery, so a second row at that address would make each of those pick one arbitrarily. - **A Supabase project on the legacy JWT secret.** Such a project cannot be a redirect upstream — Supabase will not mint an OIDC id_token under HS256 — so instead the issuer can accept an access token that project already issued, and turn it into a session here. The same person lands in the same account they would have had via the redirect flow, so migrating the project's signing keys later costs nothing. Off unless configured, and it refuses the project's own `anon` and `service_role` keys, which are signed with that very secret and are not people. - **Swedish BankID.** Not an OAuth redirect but the real relying-party flow: an animated QR code or same-device start, approval in the BankID app, and the verified personal number as the account key. The mTLS client certificate is pasted in the dashboard; the test environment works with BankID's published test certificate, so you can try it before your bank issues a production one. - **Client registration for machines.** Dynamic registration ([RFC 7591](https://www.rfc-editor.org/rfc/rfc7591)) and **Client ID Metadata Documents** (CIMD, under the MCP profile) — the mechanism [MCP clients](https://substrat.net/concepts/mcp.md) use: a client identifies itself by an HTTPS URL that *is* its metadata document, so nothing is registered, stored or rotated per client until one actually arrives. ## Identity sync on first login The first time an adapter resolves an external user it hasn't seen, it provisions them — the plan's §4.3 flow — then binds the identity: 1. mint a `PrincipalId`; 2. assign the role(s) that user should hold; 3. create the domain records they own (e.g. a customer); 4. issue any entity-narrowed grants (e.g. read-your-own-orders); 5. `linkIdentity((tenantId, provider, externalId) → principal)`. From then on `resolveIdentity` short-circuits to the same principal — a stable identity with real, enforced permissions. ## In the demo The [Kallkälla Kaffe](https://github.com/substrat-run/substrat/tree/main/demos/shop) shop signs people in through the [dev issuer](#the-dev-issuer-local) — `pnpm dev` starts it, and `/authorize` lists the cast below — plus a browse-only anonymous fallback. Picking a persona lets you *feel* the permission model; there are no passwords, because the vertical holds no credentials. | Sign in as | Role | Sees | |---|---|---| | Astrid Kallkälla | shop-admin | everything — catalogue, stock, orders, invoicing | | Gustav (lager) | warehouse | orders + stock; **invoicing is denied** | | Elin – Café Pascal | customer | the shop + **only her own** orders | | Otto – Kontoret | customer | the shop + only his own orders | | Ny Kund | customer | a fresh principal, provisioned on first login | | *(not signed in)* | public | browse the catalogue only | Signing in as Gustav and watching *Invoice basis* disappear from the nav — and 403 if you ask for it directly — is the whole thesis in one click: **the issuer authenticated you, the kernel authorized you.** --- # Events & audit Source: https://substrat.net/concepts/events.md Every mutation in a Substrat system emits a **domain event**. Events are the audit trail, the integration surface between engines, and the feed for reporting — one mechanism, three jobs. ## What module code writes ```ts ctx.emit({ type: 'workorder.completed', // module-namespaced schemaVersion: 1, entity: { entityType: 'workorder', entityId: order.id }, piiClass: 'none', payload: { orderId: order.id, billable, total }, }); ``` Everything identifying the **origin** is deliberately absent from the input. The kernel stamps the envelope below the API surface: ```ts type DomainEvent = { id: EventId; // ULID — the idempotency key for consumers type: string; schemaVersion: number; occurredAt: Instant; // stamped by kernel tenantId: TenantId; // stamped by kernel scopeId: ScopeId; // stamped by kernel actor: PrincipalId | { system: ModuleId } | { connection: string }; // stamped from the stub's context entity: EntityRef; piiClass: 'none' | 'pseudonymous' | 'direct'; subjectId?: DataSubjectId; authorization?: EventAuthorization[]; // K-34 — stamped kernel-side (see below) impersonation?: { session: ImpersonationSessionId; by: PlatformActorId }; // K-42 — stamped kernel-side (see below) payload: unknown; }; ``` The `actor` is one of three things, and never a person who wasn't one: a `PrincipalId`, a `{ system: ModuleId }` — a consumer running under a system actor, **or a module's [scheduled work](https://substrat.net/concepts/modules.md#recurring-work-schedules) firing on a timer** — or a `{ connection: string }` when a **connector** effected the write, an external provider's callback acting through a connection. A connector or a nightly job that read as a human in the audit trail would be worse than one that could not act at all, so each gets its own member rather than a synthetic principal. `authorization` records, per mutation, **which checks the emitting operation passed** (K-34): each entry names a permission that was checked-and-passed, plus — when the allow resolved through a capability grant rather than a role bundle — a ref to the granting tuple (`workorder:01J…`, `scope:01J…`). It is stamped kernel-side (module code can neither supply nor suppress it) and absent on events written before the field existed. The full proof chain is not persisted — `explain` re-derives chains on demand; what re-derivation cannot recover once tuples have changed is *which* permission and grant were consulted at write time, and that pointer is what the envelope keeps. A vertical cannot mislabel an event's origin, backdate it, attribute it to someone else, or skip emitting where an engine emits — because the fields aren't parameters and the emit sits inside the engine's operation, below anything the calling code controls. ## Impersonation: two actors on one record {#impersonation} Supporting a customer's live vertical means seeing what a named person sees. The version of that which grows by itself is a session swap — the staff member *becomes* the user, and the trail says the user did it — and that is the version an audit fails. So an operation run under an impersonation session (K-42) carries **two** actors, and every record the scope writes about who did what keeps both: - **`actor` stays the impersonated principal.** The permission model answered about them, through the same checker with no override branch, and the domain fact is theirs. A session against somebody who holds nothing is refused precisely where they would be. - **`impersonation: { session, by }` says who was holding the keyboard** — the session's id and the `PlatformActorId` of the staff member who opened it. It is `by` rather than a second `actor` because that is a different question with a different answer. Stamped kernel-side on the `authorization` pattern: it is not on `DomainEventInput`, so module code can neither claim a session it is not in nor drop the one it is — and it cannot *read* it either, which `authorization` did not need: a vertical that could see the session could hide rows from it. It is **absent** on every ordinary event. That absence is the honest reading — nobody was impersonating — not an unrecorded field, and reads that decode the envelope (`readHistory`, the denial log) return it as `null` for the same reason. A session is what bounds it. `HostAdmin.beginImpersonation(actor, { tenantId, scopeId, principal, reason, minutes?, mode? })` mints one and writes the admin-log row *before* it returns, so a durable record exists before any operation can run under it; `endImpersonation` closes one early. Its `reason` is required, its `expiresAt` is capped (`IMPERSONATION_MAX_MINUTES`, sixty) and re-read on **every** invoke, and its `mode` is `read-only` unless someone wrote down why not. Read-only is a mechanism, not a convention: the effecting verbs (`emit`, `requestPlatform`, `grant`, `revoke`, `link`) refuse by name, and the transaction is **rolled back rather than committed** — so a handler that writes a row with plain `ctx.sql.exec` and calls none of them still leaves nothing behind. The operation runs and answers; only its writes do not survive. Sessions are never deleted — one that existed is why some rows carry the stamp they do — and an expired session is a different fact from an ended one. This is not the [dev issuer's](https://substrat.net/reference/dev-issuer.md) `/dev/token`, which mints a *login* as a persona for local scripts and stamps nothing: that is a development shortcut in the issuer, this is a production trail in the kernel. ## PII classification is mandatory `piiClass` is required at the type level — an event that *could* carry personal data cannot be declared without classifying it: - **`none`** — no personal data in the payload. - **`pseudonymous`** — references a person by opaque ID (a technician reporting time). - **`direct`** — contains direct identifiers. And the schema enforces the invariant that makes GDPR erasure implementable: **a PII-classed event without a `subjectId` fails validation.** Crypto-shredding — erasing a person by destroying their key — must always be able to key the erasure. Facts and pseudonymous references survive (bookkeeping retention holds); the personal data becomes unreadable. ## Events cross boundaries, queries don't The composition rule for the whole system: engines and scopes integrate by **reacting to each other's events**, never by querying each other's tables. The [invoicing engine](https://substrat.net/engines/invoicing/index.md) demonstrates the pattern. It declares in its manifest: ```ts events: { consumes: [{ type: 'workorder.completed', schemaVersion: 1 }], } ``` and registers a consumer that parses **its own view** of the payload — it never imports the producer's types: ```ts const onWorkOrderCompleted: ConsumerHandler = (ctx, event) => { const p = completedPayload.parse(event.payload); // own Zod schema of the contract // ...write invoicing tables from the snapshot }; ``` This is a *fat event* design: the payload carries what consumers need (billable lines, prices, totals), so the consumer snapshots rather than joins. Prices are frozen at the moment the event happened — which is exactly what an invoice wants. ## Delivery semantics - Consumers run as ordinary in-scope operations under a **system actor** (`{ system: '@substrat-run/engine-invoicing' }` — visible as such in the audit trail). - Delivery is **at-least-once**, tracked in a kernel delivery journal — consumers must be idempotent; the event `id` is the idempotency key. - Ordering is guaranteed only within one (scope, module) pair. ## The connector seam Consumers run **inside** the scope, so they can only touch that scope's data. Some effects are not scope-local: adding someone to an organization writes tenant-wide directory state, outside any one scope's transaction. For those, a module *asks* and an **executor** effects: ``` module (in-scope) executor (out-of-band) ctx.emit('member.add-requested') ──▶ admin.addMember(...) commits WITH the domain write writes the audit row ``` ```ts host.registerExecutor('member-adder', 'member.add-requested', async (admin, event) => { // receives HostAdmin, not ctx — it acts with platform authority, // which is exactly what module code must never hold }); ``` Why not just write directly from the module? Because that would be a write to another database inside a scope transaction: two independent commits, no coordinator, and an orphaned membership if the scope rolls back after the directory write lands. The connector has no such hazard. The event enters the outbox in the **same transaction** as the domain write, so a rollback leaves no event and nothing to effect. The executor then runs at-least-once from the outbox, exactly like a consumer. **Dispatch is prompt** — inline after commit, with the outbox as the retry backstop. The contract is eventually consistent, because that is what makes it correct under crash, but the common case completes inside the originating request. **The trail joins.** Splitting a change across two halves would otherwise split its audit trail, so admin rows carry `causedBy` — the id of the event that caused them. That is the event id rather than a separate correlation field: the envelope already carries a unique kernel-stamped id, and reusing it avoids widening a frozen contract to say something it already says. ## How work leaves the scope {#drain} Consumers run inside the scope. Two kinds of work cannot: a **platform intent** needs authority the vertical does not hold, and an **event bound for the long-term lake** has to leave the scope's storage. Both are written in the operation's own transaction, and both are carried out later by the control plane, which is the only thing that acts on them. **Diagram — how committed work leaves a scope.** 1. **An operation commits** (in the scope · one transaction) — your rows · its events in _substrat_outbox; a platform intent, if it asked for one. 2. Two paths reach the control plane: - response flagged: **The router kicks** (latency · seconds) — returns the response to the user first; then asks for a drain · intents only, today. It names a scope, carries no data. - **The platform sweep** (backstop · every 15 minutes) — walks every active scope; catches anything a kick missed. It runs on a timer rather than a signal, and drains each active scope. 3. **The control plane drains the scope** (platform · the only thing that acts) — reads the rows itself, through the vertical's /internal routes; so a kick can speed up a scope's own work, never forge it. 4. It executes intents — **Run with platform authority** (intents) — executed outside the scope; outcome written back to its intent journal. 5. It ships events — **Shipped to the lake** (events · tier 2) — marked shipped only once the lake confirms; through the sweep only — no kick yet. The kick and the sweep are not alternatives. The kick makes it fast; the sweep makes it certain — a lost kick costs a wait, never a missed intent or event. Neither carries data: the control plane always reads the scope itself. The control plane learns there is work in two ways, and it needs both: - **The kick is the fast path.** When an operation queued an intent, the vertical flags its response. The router, the one hop that already knows which tenant and scope it served, returns the response first and then asks the control plane to drain that scope. Intents settle in seconds instead of waiting for the next sweep. - **The sweep is the guarantee.** Every 15 minutes the platform walks every active scope and drains whatever is pending, kick or no kick. A kick that was lost, failed, or never sent costs a wait, never a missed intent or event. **A kick carries no data.** It names a scope, and the control plane reads that scope's rows itself, re-deriving the tenant and vertical from its own directory. So a kick can only make a scope's *own* pending work happen sooner. It cannot put anything into the scope or the lake, which is what makes a flag any vertical can set safe to act on. **Events leave through the sweep.** Each pass reads a scope's unshipped events, sends them to the platform's Tier-2 event lake, and marks them shipped only once the lake has confirmed it holds them. The order is the safety property: a failure between sending and marking sends the same events again, which is harmless because every event has a unique `id`, while marking first could lose events and nothing downstream would notice. There is no kick for events yet, so they reach the lake on the sweep's schedule. Shipping does not remove anything. The outbox is also what consumers are delivered from, what [an entity's history](#reading-one-entity-s-history) is read from, and where an entity's version comes from, so a shipped event stays in the scope. ## Audit as a product feature Because every event carries tenant, scope, actor, entity, and time — stamped, not supplied — the event stream *is* the audit log: complete by construction, not by discipline. "Who did what, when, to which entity" is a query, and the answer is the same data reporting runs on. ## Reading one entity's history "Show me the history of this thing" has no source but the spine, so a projection read of `_substrat_outbox` is sanctioned — rule 3 bans *writes* to `_substrat_*`, not reads. What module code does **not** do is write the query: ```ts import { readTimeline } from '@substrat-run/kernel'; const timelineOp = async (ctx, input) => { const entity = timelineInput.parse(input); assertAllowed(await ctx.check(WO.read, entity)); // the caller checks, always return readTimeline(ctx, entity, input); // { entries, nextCursor } }; ``` Each entry is `{ id, type, occurredAt, actor }`. Two of those four are not what a hand-written `SELECT` would have got: - **`actor` is the union, decoded.** The column stores `JSON.stringify(actor)` over `PrincipalId | { system } | { connection }`, so a principal is stored *with its quotes*. `SELECT actor` returns a string that looks usable and is not — resolving a name against it misses every time, and the obvious repair (trim the quotes) then breaks on a system actor. - **`id` is the entity's version at that point.** The same token [`ctx.versionOf`](https://substrat.net/concepts/api-design.md#_7b-a-read-modify-write-says-what-it-is-writing-over) returns and `If-Match` compares, so listing the history, naming a version, and refusing a stale write are one vocabulary. It is therefore the cursor: `ORDER BY id` is creation order, because the mint is monotonic. The timestamp inside the id is the operation's instant — the same one `occurredAt` carries — so the cursor and the column agree about when, and paging by one does not reorder the other. Monotonicity is the stronger promise of the two, and where they part company it is the one that wins: if the clock goes *backwards*, `occurredAt` reports what it said and the id holds at the last instant it stamped, because an id that followed the clock down would bury a newer row underneath an older one. That floor survives a restart, because it is seeded from the outbox itself: the highest id a scope has already stored is read back when the scope is opened — when a host reopens its directory, and when a scope's Durable Object is revived after an eviction — so the next id clears the rows on disk whatever the clock now says. The floor is per scope, which is the scope the ordering is defined over. Do not page a spine read on `occurred_at`. `ctx.now()` is stable for a whole invocation, so every event one operation emits carries the *identical* instant — a cursor of `occurred_at > ?` silently drops the rest of the burst it lands in. `readHistory` is the same walk with `payload`, `authorization` (which permission and which grant allowed the change), `impersonation` (the staff actor behind the change, when there was one — see [Impersonation](#impersonation)), `piiClass`/`subjectId`, `operation` (the exact `invoke()` string the event was emitted from), `version` (the version the emitting code was deployed as), `causedBy` (the event whose delivery was in flight when this one was emitted) and `invocationId` (the call the event belongs to). The last four are stamped kernel-side on the same pattern as the authorization chain and the impersonation stamp: module code can neither forge nor suppress them. Seven nullables there are facts rather than gaps, and they are seven *different* facts: - `payload` is **null after an erasure** — the envelope survives a shred, so a history correctly degrades to "someone changed this, then". - `authorization` is null when the row **predates it being recorded**, which is not the same as having checked nothing. - `impersonation` is null when **nobody was impersonating** — the ordinary case, not an absence of recording, because the kernel stamps it on every event raised under a session and on no other. A history strip that cannot show this shows a customer's own name against a change their support engineer made. - `operation` is null for a **consumer's emit** — a consumer runs on behalf of no operation, so the null is a fact, like `impersonation`'s — *and* for a row written before the column, where it is unrecorded, like `authorization`'s. This is the one null that honestly carries both neighbouring meanings, and the spine cannot tell them apart after the fact; render it as "—" rather than guess. A schedule's emit is not a consumer's: the schedule invokes an operation, so its events carry that operation's name. - `version` is null when **no version identity was present** — an undeployed host (dev or self-host SQLite with no configured id), a script deployed before the `SUBSTRAT_VERSION_ID` binding existed, or a pre-column row. It is surfaced from the outbox column only: the envelope `ctx.emit` builds deliberately never carries it, because which push wrote an event is script configuration rather than event data, so `readHistory` is the one sanctioned way to join an event to the deploy that produced it. A reader who expects it on the envelope, or hand-rolls a `SELECT` without the column, gets a null that looks like missing data. - `causedBy` is set **only when the emit happened inside a delivery in flight** — a module consumer, or a connector or platform executor, handling an event — because that is the only time a cause exists to record. It is the step `authorization` and `operation` never wrote down: neither says *why* a consumer emitted, so a backwards walk used to stop at the first consumer hop. Null means an operation emitted the event directly (the ordinary case) or the row predates the column. Where `operation` is also null and this is set, the pair is decisive: the event came from a consumer, which neither field could establish alone. `walkEventCause` and `walkEventEffects` in the kernel follow it in each direction, and name why a chain ends rather than stopping silently. `readInvocation` groups by the call instead, which is how two events one operation raised independently — no cause between them — are read together. - `invocationId` is null when **no call carried one** — a seed, an internal call, or a row written before the column. It is the grouping key rather than a link: `causedBy` joins one event to one other, while this says two events were the same unit of work even when nothing causal connects them. Stamped once per invocation and carried through the post-commit tail, so a consumer's emit shares the id of the call that set it off. `readInvocation` is the read that uses it. `readTimeline`'s entry deliberately gains none of `operation`, `version`, `causedBy` or `invocationId`: the timeline is the envelope and nothing more, so there is still no disclosure decision to make there. Field-level "X → Y" comes from diffing consecutive payloads; nothing stores a before-state. For the few fields a history strip actually shows — status, owner, value — putting the previous value in the fat payload is more honest than making every reader diff for it. Neither read checks a permission. That stays the caller's line, above the call: a helper that gated itself would be a second, invisible policy surface, and one that gated itself on nothing would be an unchecked path into every event in the scope. --- # Snapshots & test copies Source: https://substrat.net/concepts/snapshots.md You're about to upgrade *Acme HR* to a new version, and the new version changes the database schema. If the migration mangles a table, "undo" is not a button — the data has moved. What you want is what you'd do with a spreadsheet before a risky edit: **save a copy first**. A **snapshot** is that copy, for a whole app: the app's entire database, duplicated at a moment in time, kept as its own independent thing. The original keeps running; the copy doesn't change when the original does; you can look at it, run the app against it, or throw it away. Three everyday problems, one primitive: | You want | You get | |---|---| | "Let me try this on real data without touching the live app" | a **test copy** — fork it, poke at it, delete it | | "Will this upgrade survive our real data?" | **snapshot-before-update** — a copy taken automatically just before a risky upgrade | | "Give me real-shaped data on my laptop" | [`substrat scope pull`](https://substrat.net/reference/cli.md#scope-pull) — a governed, masked export to a local SQLite file | ## The picture Code moves along one line (versions); each app's data moves along another. A snapshot branches the data line — and, unlike a git branch, it never merges back: ```mermaid gitGraph commit id: "v1 installed" commit id: "daily use…" branch snapshot-jul-25 checkout snapshot-jul-25 commit id: "frozen copy" type: HIGHLIGHT checkout main commit id: "upgrade to v2" commit id: "daily use continues" ``` The live app moves on; the copy stays exactly as it was. If v2 turns out bad, the copy — still runnable, still on v1's schema — is the rollback point. ## Why a copy is enough (no database-branching magic) Neon and PlanetScale branch databases with copy-on-write storage engines. Substrat doesn't need one: a [scope](https://substrat.net/concepts/tenancy.md) is **one tenant × one app** — small by construction — so a full copy takes seconds. And because the copy *is* a scope, it isn't just data: the **same app code runs against it**. A database branch elsewhere gives you rows; a snapshot here gives you the running app as it was. ## The one rule everything follows Module migrations are append-only — never edited, never un-run ([modules](https://substrat.net/concepts/modules.md)). Once data has moved to a new schema, there is no moving it back. Every snapshot behavior is that rule, cashed out: - A copy is taken **at the source's current version** — so it runs as-is, no migration needed. - A copy can move **forward** (bind a newer version; its migrations run on the copy) — which is how you rehearse an upgrade on real-shaped data before the live app takes it. - A copy never moves **backward**, and neither does the live app — "rollback" means *restore the pre-upgrade copy*, accepting that writes made since are discarded. Because that price is real, the platform pays for the insurance automatically: > **Tip — Snapshot-before-update** > > When an app is updated to a version whose **migrations changed**, the platform takes a snapshot > first — that's the checkbox (on by default) next to "Update to latest" in the Dashboard. A > code-only update takes no snapshot: rebinding the previous version already undoes it. ## Copies expire (on purpose) A snapshot is real customer data occupying real storage: - **TTL** — pick 1/7/30 days when creating one; the platform's scheduled sweep deletes expired copies — storage wiped, the deletion audited. An auto-deleting copy is also a smaller privacy liability than one that lingers. - **Keep** — a copy without a TTL stays until someone deletes it. - This delete is the platform's **only hard delete**, and it refuses anything that isn't a copy. Live apps only ever archive; an ephemeral copy is the one thing allowed to truly disappear. ## Where the data goes (and doesn't) An app's data lives in its own deployment, not in the control plane ([control plane](https://substrat.net/platform/control-plane.md)). Making or deleting a snapshot is an instruction — *"copy A into B"*, *"wipe C"* — carried out **inside the app's own deployment**. The platform keeps only the catalog card: where the copy came from, when, and when it expires. **No app data crosses to the platform** for any of it. The one deliberate exception is [`scope pull`](https://substrat.net/reference/cli.md#scope-pull), whose whole point is moving data out — so it is staff-gated, audited, **pseudonymized by default** and refused entirely for data pinned to a jurisdiction. Columns and payload fields a name heuristic *recognises* as PII carry deterministic fake values — the same customer reads the same on every screen — while free text and national identifiers stay `[masked]` and a column the heuristic does not recognise is untouched. `--full` is an explicit break-glass. The guarantee is therefore about the recognised fields, not about the file: it is pseudonymization, not anonymization, and a heuristic one at that. Rare combinations, amounts, dates and anything the sweep did not claim can still re-identify a subject, so the copy is handled as personal data either way. ## What a snapshot is *not* - **Not backup/PITR.** Durable Object storage already has ~30-day point-in-time recovery — a destructive rewind of the live app. A snapshot is the opposite: a non-destructive copy that leaves the live app alone. Recovery rewinds; snapshots preview. What backs up *the platform's own directory* — the one thing PITR cannot cover, since it is a single Durable Object with nothing underneath to rebuild it from — is [the control plane's scheduled directory backup](https://substrat.net/platform/control-plane.md#backup-and-recovery). - **Not a sync.** The copy diverges the moment it's taken, and never merges back. - **Not a second production.** A copy receives no traffic, runs no integrations or scheduled work, and expires. It contains real data, so the same access rules apply — but nothing you do in it touches the live app. ## Where you meet it - **Dashboard → app → Data → Previews** — create a test copy with a TTL, watch the expiry countdown, delete ([dashboard](https://substrat.net/platform/dashboard.md#previews-environments)). - **Dashboard → app → Deployments** — "Update to latest" with **Snapshot data first**. - **CLI** — [`substrat scope pull`](https://substrat.net/reference/cli.md#scope-pull) for the local inner loop. --- # The deploy model Source: https://substrat.net/concepts/deploying.md A vertical you build on Substrat runs as a **hosted app**: one Durable Object per tenant scope, its own SQLite, served by code you pushed. This page is the model behind getting that code from your laptop into production and keeping it there safely — the concepts the [deploy how-to](https://substrat.net/guide/deploying.md) and the [dashboard](https://substrat.net/platform/dashboard.md) both assume. The mechanics of the `substrat` CLI live in the how-to; the *shape* lives here. ## Push vs deploy Two distinct acts, deliberately not fused: - A **push** uploads a version. The CLI builds your worker locally, POSTs the bundle plus a manifest to the control plane, and the control plane records an immutable version (a `deploymentRef`, a permission digest, a migration digest). A push does **not** serve anything. - A **promotion** points a **channel** at a version and makes scopes run it. This is the deploy. The split exists because "here is a new build" and "run this build against real data" are different decisions with different blast radii. A push is cheap and reversible — it just adds a version to the registry. A promotion changes what a live tenant sees. ## Admission: may this code run here? Before a version can be promoted it must be **admitted** — the answer to *"may this code run on our infrastructure at all?"* Admission is answered **mechanically**, by the sandbox contract: the bundle may declare only its own durable stores (a positive binding allowlist — no `CONTROL_PLANE`, no platform secret, and no binding whose name starts with `SUBSTRAT_`), it runs inside a Workers-for-Platforms isolate, and it is held to quotas. If the bundle satisfies the contract, nothing else is in question. The `SUBSTRAT_` refusal is by **name prefix**, whatever type the binding claims, and the reason admission gives is the whole rule: *"the `SUBSTRAT_` binding namespace is the platform's — these names are injected at deploy, never declared"*. A vertical that could declare `SUBSTRAT_VERSION_ID` itself would collide with the injected one — a duplicate name on the upload at best, a forged version stamp at worst — so the namespace is reserved as a whole, and a push whose declared bindings carry any `SUBSTRAT_*` name (a Durable Object namespace or a D1 store bound under it, or anything a future client declares) is refused with that message rather than admitted. Name your own bindings and config outside the prefix. **Reaching the outside world is not a binding — it is a granted capability.** The allowlist deliberately excludes egress-shaped bindings (`send_email`, `ai`, `browser`, …): a hosted vertical never talks to a third party by *declaring* one. When a vertical genuinely needs a platform capability — sending transactional email, answering with a language model, provisioning tenants — it *declares a request* in its manifest (`substrat.sendsEmail`, `substrat.usesModels`, `substrat.provisions`) and something outside the bundle turns it on: a **staff grant** for email and provisioning (`setVerticalEmailSender`, `setVerticalTenantProvisioner`), a fleet-wide platform switch for models. The request is refreshed on every push, grants nothing by itself, and no push can set the flag it needs — but what that buys differs, and the difference is worth stating plainly. For email and provisioning the flag is **per vertical**, so a push can never acquire the capability at all. For models the switch is **fleet-wide**: a version that adds `substrat.usesModels` while the platform has models on does get the binding, and what a push cannot do is turn models on for a fleet that has them off. That declaration reaches a human at admission on a listed vertical; on a private one — auto-admitted, blast radius its own tenant — it is on the record rather than reviewed. What the platform hands back differs by capability, and the difference is worth naming: - **A relay, for a capability that rides a credential.** Email is the reference: the vertical POSTs to `POST /internal/email/send` on the control plane — the one worker holding an outbound-mail credential — which sends on its behalf and re-checks the grant on every call. The vertical holds no outbound credential and reaches no third party directly. - **A real runtime binding, for models.** `substrat.usesModels` is the one request answered with a binding instead of a relay: the uploader appends `{ type: 'ai', name: 'AI' }` to the script's bindings *after* the allowlist check has run on the declared set, so a vertical still cannot declare `ai` for itself — it can only be handed one. It takes **both** halves to appear: the platform willing to bind at all (a fleet kill-switch), and *this version* having asked. A vertical that never declared it gets no binding and falls back to whatever key its own env carries. The credential stays the platform's — the binding runs on the platform's own AI account, and nothing about it is in the bundle — but the honest bound is that a vertical holding it can call `env.AI.run()` outside the metered path. - **A version identity, on every deploy.** The uploader also appends a `plain_text` binding named `SUBSTRAT_VERSION_ID`, carrying the id of the version being served. Nothing has to be declared for it: it is the platform's stamp, not a requested capability, and it is what lets the scope host record which push wrote an event — the `version` that [`readHistory`](https://substrat.net/concepts/events.md#reading-one-entity-s-history) surfaces and the dimension the observability views group by. An in-place promote refreshes it to the version now being served rather than inheriting the outgoing one. Read it from `env.SUBSTRAT_VERSION_ID` if you need it; never declare it, or anything else under that prefix — admission refuses the namespace, as above. Either way, "how a vertical gets a dependency" is: declare the request, get it granted, call what the platform hands over — never bind the raw resource yourself. Who has to *vouch* for a version depends on who will be exposed to it (decision D-36): - **Private vertical** (you own it, it is not listed on the marketplace). The only tenant that can run the code is the workspace that wrote it — you. There is no third party to protect, so the sandbox contract is the entire gate and a push lands **admitted automatically**. An auto-admission note records that no human vouched, so the platform can still tell an auto-admitted version from one a human reviewed. - **Listed vertical** (published to the marketplace, so *other* tenants can install it against *their* data). Now a second question appears — *"may other people run this code against their data?"* — and that is a **human** decision. A listed vertical's pushes land **pending** for staff admission, and its production promotion is a staff decision too. The human gate did not disappear; it moved to the boundary where it means something. The place a private vertical becomes listed — `substrat publish`, the `setVerticalListed` decision — is where a human vouches. Publishing is the checkpoint, not every push. ## The one channel: `prod` A vertical has exactly **one channel — `prod`**: the pointer at the version its scopes serve. There is no `dev` or `staging`. Those existed once and were *write-only* — nothing ever read or served them (#509) — because a channel names a *pointer at code*, and an immutable version id already does that. What a real non-production environment needs is not a second pointer but a second **scope with data**, which is a [preview](#previews-run-a-version-against-a-copy-of-the-data). The full argument, and how to run test / canary / release-candidate environments on previews, is [Environments & previews](https://substrat.net/guide/environments-and-previews.md). `prod` stays a *moving target* — a [promote](https://substrat.net/guide/deploying.md#promote-to-prod) re-points the prod scope at a new version — but it is a **rebind**, not a rename: the same scope, the same data, serving new code. For a vertical you own privately you self-serve it, so `substrat push --promote prod` is a complete deploy and a merge-to-main workflow can be the deploy. A prod promote re-points your live scopes in the same act — there are no *other* tenants who would be forced into lockstep (that concern, from decision D-30, is a shared vertical's many tenants, which a private vertical cannot have). For a shared vertical, that cascade is exactly why prod is a fleet-wide rebind, and why bringing *one* tenant forward first is a [per-scope bind](https://substrat.net/guide/environments-and-previews.md#canary-pinned-tenants-per-scope-rollout), not a channel. Two things a promotion always respects: - **The surface checkpoint.** A promotion whose **permission** or **migration** digest differs from what is live is refused until the diff is acknowledged. This is the same two-human- checkpoint discipline the kernel applies to [modules](https://substrat.net/concepts/modules.md#two-human-checkpoints), applied at the deploy boundary — with the owner as the human at their own checkpoint. - **Channel history.** Every promotion appends a row: what went live, what it replaced, who did it, and exactly when. That history is the dashboard's rollback picker, and each timestamp is the instant a point-in-time rewind would restore the data to. ## In-place updates: data carries forward The property that makes "promote prod from a laptop" safe is that a vertical serves **in place** (decision D-37). A version update is *not* a rebind to fresh, empty storage: - A prod promote re-uploads the promoted bundle onto the vertical's **one stable serving script**. The scope's Durable Object and its SQLite **stay put** — data does not move. - Because the data is still there, the kernel's append-only **migrations run forward against production data**, exactly as designed. This is the mechanism the whole migration model is built around; before in-place serving it never actually ran in production. - Secrets survive the deploy. - **A promote repairs its own installs.** Each scope records the version its provision hook last ran against (`provisionedVersionId`), and once a scope is bound to a new version — by a promote or a per-scope bind, never by an upload alone — the platform sweep re-runs `/internal/reconcile` on every active install whose receipt is behind the version it now serves. So whatever a new release's `onProvision` mints reaches existing installs too, which is why that hook must be idempotent. The how-to is in [Deploying a vertical](https://substrat.net/guide/deploying.md#ship-it-substrat-push). A version is badged **code-only** or **schema-change** at publish, so you know whether a promote touches the schema. A code-only update just re-points; a schema-change update runs migrations, so it wants its migration diff acknowledged first. ## Previews: run a version against a copy of the data Before a promotion whose *migrations* changed, you often want to see the new version run against real-shaped data **without** touching production. Substrat can do this because the scope-host contract already runs identical module code on two adapters — so a **preview** is a **fork** of a scope's data bound to the new version, reachable at its own URL. The governing law is that [migrations are forward-only](https://substrat.net/concepts/snapshots.md): a snapshot taken today (at prod's migration frontier) bound to *today's or a later* version rolls its migrations forward on the copy — rehearse the new version against real-shaped data, throw the fork away if it breaks, and prod never saw it. The inverse — pointing *old* code at *today's* data across a schema change — is invalid, because you cannot un-run a migration. So the rollback strategy is **fork-before-you-promote**: snapshot right before binding the new version, and a bad version leaves the prior one still runnable at its correct frontier. A fork is a governed dead end — the export copies the kernel spine too, so no connector, cron, or billing consumes from a preview — and preview URLs are non-public, gated code running against real-shaped data. The everyday shape of all this — test copies, fearless upgrades, real data pulled to a laptop — is [snapshots & test copies](https://substrat.net/concepts/snapshots.md). ## Backup, restore, and backout Because every app is one scope with its own database, recovery is a per-tenant primitive, not an environment-wide runbook — one tenant's rewind is a clean, self-contained blast radius. - **PITR rewind — the first-hours backout.** Right before an upgrade migrates, the scope DO bookmarks the instant (Durable-Object point-in-time recovery, ~30 days of history). If a promotion goes wrong, an **audited rewind** rolls the scope's data back to that bookmark, time-boxed to **~24h** unless forced. A rewind is a *destructive, in-place* rollback of that one DO — the opposite lever to a fork, which is a non-destructive copy. It rewinds the whole database, so a stale bookmark is refused rather than silently skipped. One sharp edge: data and version binding live in different DOs, so rewinding *past a bad migration* means also rebinding the version — two coordinated rewinds, no atomicity between them. - **Backup / restore — the considered path.** `substrat scope restore` loads a backup into an existing hosted scope, replacing its data — a pulled `.sqlite`, a local adapter-sqlite scope file, or a dump. This is the deliberate recovery when a time-boxed rewind is not the right tool. - **Legacy adoption.** A scope that predates the stable serving script hops onto it once with `adopt-serving` (export → restore → flip, data-first), so future promotes stop stranding its data. ## The whole shape Push uploads a version; admission asks *may this run here* (automatic when you are the only tenant exposed, staff when you list it); promotion points a channel and serves **in place** so data carries forward across every update; previews rehearse a risky version against a fork of real data; and PITR rewind plus backup/restore are the backout. The staff human gate lives at **publish** — the moment other tenants can run your code against their data — and nowhere else in the loop. See also: [Deploying a vertical](https://substrat.net/guide/deploying.md) (the how-to), [Snapshots & test copies](https://substrat.net/concepts/snapshots.md), and [The platform layer](https://substrat.net/concepts/platform.md). --- # Reads & scaling Source: https://substrat.net/concepts/reads.md Every scope runs a **strictly serialized executor**: one operation at a time, run to completion ([Operations & the scope host](https://substrat.net/concepts/scope-host.md)). That guarantee is what lets module code do a read-modify-write without locks, transactions-for-concurrency, or retry loops. It also raises the obvious question — *doesn't that mean one request at a time?* No. But the reason it doesn't is worth understanding, because the intuitive fix is the wrong one. ## Serialization is per scope, not per system The unit of serialization is the **scope**, and a scope is sized to the *consistency domain* — one housing association, one branch, one clinic — never the whole tenant ([Tenants & scopes](https://substrat.net/concepts/tenancy.md)). A thousand scopes execute a thousand operations at once: separate execution domains, separate databases, possibly separate machines. Only two operations *on the same scope* ever queue. That queue is also much cheaper than it sounds. A scope's data is local to its execution domain, so an operation is local SQLite work — microseconds to a couple of milliseconds — not a network round-trip per query. And module code [cannot call the network](https://substrat.net/concepts/modules.md), so an operation can never hold the scope's turn while waiting on a slow third party. The queue drains fast because operations are short. ## Why you can't just parallelize reads The tempting fix is to let read-only operations run concurrently. It buys you almost nothing. A scope's execution domain is a **single isolate** — one thread. Admitting reads concurrently would interleave them at `await` points, not run them in parallel. And SQLite reads inside the domain are *synchronous local calls*, so there are no `await` points to interleave at. Ten concurrent reads would still execute one after another, just with more bookkeeping. > **Reads get fast by getting short, not by getting concurrent.** A queue of 50 reads at > 30µs each is invisible. At 2ms each, it isn't. Attack the duration. ## The three read paths Reach for them in this order. | Path | Latency | Consistency | Use for | |---|---|---|---| | **In-scope** (default) | µs | Serializable | Everything interactive | | **External read model** (escape hatch) | ms | Eventually consistent | A scope whose reads outgrow its executor | | **History tier** (Iceberg / R2 SQL) | seconds | Eventually consistent | Reporting, audit, cross-scope | ### 1. In-scope reads — the default A read inside a scope is a local indexed query: one hop, strongly consistent, tens of microseconds. Nearly all interactive reads belong here. When a screen needs a shape the normalized tables don't serve cheaply — a dispatcher board joining jobs, customers, technicians and totals — don't reach for a cache or a replica. Keep a **projection table in the scope's own database**, maintained by an event consumer in the same transaction as the write it derives from: ```ts // The vertical's own module registration. export const fsmModule: ModuleRegistration = { manifest: { // ... events: { emits: [], consumes: ['workorder.completed'] }, }, consumers: { // Fed by the engine's event. Own table, keyed by the engine's id — // never a column added upstream, never a read into engine tables. 'workorder.completed': (ctx, event) => { const { workOrderId, completedAt } = JobCompleted.parse(event.payload); ctx.sql.exec( `UPDATE fsm_job_board SET status = ?, completed_at = ? WHERE job_id = ?`, ['completed', completedAt, workOrderId], ); }, }, }; ``` The read is then one indexed scan of one flat table. There is **no staleness**, because there is no second store — the projection commits with the write that caused it. This is the same [side-table pattern](https://substrat.net/concepts/modules.md) verticals already use to extend engine entities, applied to read performance. ### 2. External read model — the escape hatch If a scope's read volume genuinely outgrows its executor, the **outbox** already gives you the way out. Event emission is transactional with the write it describes ([Events & audit](https://substrat.net/concepts/events.md)), so a second drain sink can maintain a denormalized read model in external storage (D1, KV) without any risk of a write that never reaches it. > **Warning — Read-your-writes does not survive the crossing** > > D1's Sessions API provides sequential consistency *within D1's version space* — a > *bookmark* names a D1 version, and a replica waits to catch up before answering. But the > authoritative write lands in **the scope**, and reaches D1 only after the outbox pump runs. > At the moment the operation returns, there is no D1 bookmark that means *"after my write"* > — the two version spaces are unrelated. > > So the guarantee you'd be counting on is exactly the one that breaks. Recovering it means > either mapping event id → D1 bookmark in the pump and waiting on that watermark, or > pinning a session's reads back to the scope for a window after it writes. Choose one > **before** adopting this path. Note what this implies: inside a scope you have something *stronger* than session consistency for free — full serializability, one copy, no bookmarks to thread. Read-your-writes isn't a problem you have; it's a problem you'd **acquire** by leaving. ### 3. History tier — not a read tier Domain events flow to Iceberg on R2, queried through a tenant-scoping gateway. That tier is columnar and seconds-scale by construction: it is for **reporting, reconciliation, audit, and cross-scope history**. It is not a UI list view, and treating it as one is a category error. **Diagram — the three read paths.** Reach for them in this order. The further a path sits from the scope that owns the data, the staler it is allowed to be. 1. **In-scope read** — µs, serializable. For everything interactive. Inside the scope boundary: a projection table in the scope’s own database, committed with the write that caused it. 2. **External read model** — ms, eventually consistent. For a scope whose reads outgrow its executor. Outside the scope boundary: fed by events off the spine. 3. **History tier** — seconds, eventually consistent. For reporting, audit, cross-scope. Outside the scope boundary: exported to Iceberg / R2 SQL. Distance from the boundary is the staleness. Path 1 has none — there is no second store, because the projection commits with the write that caused it. Path 3 is not a read tier for anything a person is waiting on. ## Finding a row by what someone typed A picker is not a list. `listCustomers` sorted by number is the right read at forty customers and the wrong one at forty thousand — and once a list is paged, filtering the page in the browser searches the first page only. Declare the entity **searchable** and the kernel builds a real index for it: ```ts // The vertical's manifest, through `manifestEntities` — the same helper that checks // `fields` against the entity registry. ...manifestEntities(calloutEntities, { searchables: [ { entityType: 'customer', fields: ['name', 'number'] }, // `substring` matches inside a word ("ande" → "Andersson") for a larger index. { entityType: 'note', fields: ['body'], tokenizer: 'substring' }, ], }), ``` From that declaration the kernel provisions a per-scope FTS5 index and the triggers that maintain it, journaled like any other migration. Nothing else changes: rows are still written with `ctx.sql`, and the index stays correct because the trigger fires no matter who writes. That also means it is **read-after-write correct** — a customer created in one breath is findable in the next, with no indexing lag to wait out. The read returns ids, and you hydrate them through the query you already have: ```ts const searchCustomers = async (ctx, { q, limit = 20 }) => { assertAllowed(await ctx.check(PERM.customerManage)); const hits = ctx.search('customer', q, { limit }); if (hits.length === 0) return { results: [], limit, capped: false }; const rows = ctx.sql.query( `SELECT * FROM callout_customers WHERE id IN (${hits.map(() => '?').join(', ')})`, hits.map((h) => h.id), ); const byId = new Map(rows.map((r) => [r.id, r])); // `IN (…)` loses the rank order — put it back, or the best match lands third. return { results: hits.map((h) => byId.get(h.id)!), limit, capped: hits.length === limit }; }; ``` Three things worth knowing before you design a screen around it: - **It is capped, not paged.** A relevance order has no stable sort key, so it has no honest cursor either — paging a ranked result set reorders rows, and rows go missing or double. Ask for the top N and tell the user to narrow the term. Deep, ordered paging is what a declared sort on a list read is for. - **It does not check permission.** Nothing on `ctx` does. Your `assertAllowed` comes first, and if your entity uses narrowed grants, filter the hits you hydrate — a ranked top-N filtered afterwards returns *fewer* than N, so over-fetch deliberately. - **Give it its own route.** `GET /customers/search` beside `GET /customers`, not a `q` parameter on the list: the two have different pagination contracts and one endpoint cannot carry both. The static segment is registered ahead of `/customers/{id}` automatically, so the two never collide. Short terms are refused rather than answered by a scan — two characters for the default `prefix` tokenizer, three for `substring`, which is the trigram index's own floor. Enforce the same minimum in your operation's input schema so a caller gets a 400 that names the field. ## Two more surfaces, and neither is a read tier Distinct from the three read paths, a vertical's manifest can declare stores the platform mints and injects for it: - **A per-tenant relational store** (`tenantStores`, opened at runtime through `host.openTenantStore`) — one independent SQL database per tenant. It is an *own-store* concept, the way a vertical might keep an auth database. - **A per-tenant blob store** (`blobStores`) — object storage behind the kernel's [attachment](https://substrat.net/concepts/modules.md) surface, for the documents and images a scope's rows point at rather than contain. Both follow the same ownership rule, and it is the interesting part: **the builder supplies no id**. The vertical declares a *need*; the platform mints the database or bucket, holds the cloud credential, and attaches a binding to the serving script. A vertical is handed a store — it never names one, so it can never name someone else's. Neither is a read-scaling tier. Reach for them when a vertical genuinely needs storage outside the per-scope execution domain, not to make scope reads faster. Scope reads still follow the three paths above. ## Why not global read replicas? Because the operational record shouldn't leave, for three reasons: - **Residency.** A scope's `jurisdiction` is fixed at provisioning and its execution domain can never relocate. Replicating an `eu` scope's data to other regions contradicts that guarantee — and D1 offers location *hints*, not jurisdiction guarantees. - **Consistency.** Read-your-writes does not cross the outbox, per above. Bookmarks cannot repair a boundary they cannot see. - **The workload doesn't want it.** A scope maps to *one business*, whose users cluster around it. **Placing the execution domain well at provisioning beats replicating it.** The honest carve-out: global replication earns its keep on **public, read-heavy, staleness-tolerant surfaces** — customer portals, tracking links, availability views — where the projection is a derived subset rather than the operational record, and traffic can dwarf internal usage. That's a per-surface decision, not a platform read tier. ## When a scope really is too hot Serialization bounds *write* throughput on a single scope, and at some point a scope can outgrow its execution domain. The answer is never "shard the scope": - **Split it.** A scope that's too hot is usually a consistency domain drawn too large. This is the same move as the [granularity rule](https://substrat.net/concepts/tenancy.md) — and it's the right answer more often than it looks. - **Migrate it to storage shape B**, where the execution domain becomes a control plane (hot state, locks) fronting a separate database for bulk storage and read replicas. The choice is per scope, and **invisible to module code**. Both remain available for the same reason everything else here does: module code reaches data through `ctx.sql` and nothing else, so what sits underneath can change without a single operation being rewritten. --- # The model Source: https://substrat.net/concepts/model.md A vertical's **model** is one TypeScript module — `spec/model.ts` — declaring what exists: its entities, the operations over them, and the permissions those operations check. It is not a schema language. It is TypeScript, because the compiler is what checks the joins between those three things — and those joins are where the defects live. ## What it declares, and what it must not | tier | contents | written by | |---|---|---| | **Declared** | entities, fields, relations, operations, permissions, events, returns, [lifecycles](https://substrat.net/concepts/lifecycle.md) | the model, human-approved | | **Prose** | pricing, denial reasons, the seed cast | `spec/concept.md` | | **Emitted** | `model.json`, the manifest's entity fragments, derived permission and event lists | code | | **Authored** | handler bodies — the business logic | you | The line: **the model says what exists and what is legal; prose says what to do about it.** > **Warning — This page used to say the opposite** > > Until #844 this table put state machines under *Prose*, and this paragraph read *"if you > find yourself inventing a way to declare a state transition, the boundary has slipped."* > > That was wrong in a way the codebase had already demonstrated. Six entities across four > engines and two demos carried a `status` enum, and every one of them described its > transitions a second time as hand-written guards in operation bodies — held to the enum by > nothing. One engine wrote its state *set* out twice, as two independent `z.enum` literals. > The boundary had not been holding; it had been quietly redrawn at every call site. > > What the old line was protecting is still protected, and now explicitly: a lifecycle > declares **which states exist and which operation moves between them**, and it cannot > declare an action, a condition, an effect or a timer. See > [Lifecycles](https://substrat.net/concepts/lifecycle.md#what-a-lifecycle-cannot-say). There is deliberately **no tenancy annotation**, and there never will be. An operation runs inside a scope that already *is* a tenant, and `ctx.sql` cannot reach another — so there is nothing to forget. The best thing a modelling vocabulary can do with a rule is not need to express it. ## Entities ```ts import { defineEntities, emitModel } from '@substrat-run/contracts'; import { z } from 'zod'; export const entities = defineEntities({ customer: { table: 'acme_customers', fields: z.object({ id: z.string(), number: z.string(), name: z.string(), created_at: z.string(), }), key: ['number'], erasable: ['name'], }, site: { table: 'acme_sites', fields: z.object({ id: z.string(), customer_id: z.string(), address: z.string() }), parents: ['customer'], }, }); ``` Field names mirror the SQL columns exactly, snake_case included. A prettier domain naming here would be a second description of the same rows, and two descriptions are how they come to disagree. **Not every table is keyed by `id`.** `primaryKey` defaults to `['id']` and is declared where the identity is something else — most often the `vertical_` side table the [composition rules](https://substrat.net/guide/agent-rules.md) prescribe for extra data on an engine's entity: ```ts // Its identity IS the work order's. An `id` of its own would permit two side rows // for one work order, which is the thing a primary key exists to prevent. workorderExt: { table: 'vertical_workorder_ext', fields: z.object({ workorder_id: z.string(), route_note: z.string().nullable() }), primaryKey: ['workorder_id'], }, // And the ordinary value-keyed shape: one budget per customer per month. budget: { table: 'vertical_time_budget', fields: z.object({ customer_id: z.string(), year: z.number(), month: z.number(), hours: z.string() }), primaryKey: ['customer_id', 'year', 'month'], }, ``` It stays separate from `key` because SQL's own distinction is the useful one: `primaryKey` is identity, `key` is an additional uniqueness rule, and a table legitimately has both. An entity with neither an `id` field nor a `primaryKey` is refused rather than emitted keyless. An entity is still something the platform can *point at* — attachments hang off one, [grants](https://substrat.net/concepts/permissions.md) narrow to one, `ctx.link` joins two, an event is about one — and all of that needs **one** id. So a composite key makes an entity un-pointable, and the compiler enforces it: `parents`, `attachmentTargets`, `relations`, `emits.entity` and a narrowed `permission.entity` accept only single-column-keyed entities. ```ts attachmentTargets: [{ entityType: 'budget', readPermission: 'x:read' }], // ~~~~~~~~ // Type '"budget"' is not assignable to type '"customer" | "ext"'. ``` A composite-keyed table is still a full model member — migrations, a row type, a place in `model.json`. It is simply not something a grant can narrow to. Note that `ext` above stays pointable: a single-column key that is not called `id` is still one id. The rule is derived from the key rather than declared. A `pointable: true` flag would be a second description of what `primaryKey` already says, and two descriptions are how they come to disagree. `parents` is plural and takes an array because `entityRelations` is an **allowlist**: the kernel accumulates permitted parent types into a set, so an entity legitimately has more than one. ## Operations ```ts export const PERMISSIONS = ['customer:manage', 'customer:read'] as const; export const operations = defineOperations(entities, PERMISSIONS)({ 'acme/create-customer': { summary: 'Register a customer', permission: 'customer:manage', input: z.object({ number: z.string(), name: z.string() }), output: entities.customer.fields, http: { method: 'POST', path: '/customers' }, emits: { entity: 'customer', entityIdFrom: 'id', type: 'acme.customer-created', schemaVersion: 1, piiClass: 'none', payload: ['id', 'number'], }, }, }); ``` `input` is a **real Zod object**, not a description of one. That is the whole reason the model is TypeScript: a schema language would need the shape written twice, and transcription is where argument names go wrong. Being real is also what lets the **host** parse with it. Hand your derived schemas to the registration and every invocation is parsed before your handler runs — over HTTP, from a test, from a seed, from a schedule: ```ts export const shopModule: ModuleRegistration = { manifest: shopManifest, operations: { 'shop/checkout': checkoutOp, … }, operationInputs: operationInputsOf(shopOperations), }; ``` So a handler receives input that has already been parsed: unknown keys are gone, declared defaults are applied, and a malformed call never reached it. You do not write `.parse()` in the handler, and a new operation cannot forget to. Omit `input` entirely for an operation that takes no body. ### Where `PERMISSIONS` comes from That array is the one thing in the model written twice: the keys are also declared, with their prose descriptions, in each module's manifest. It cannot be derived from there — a manifest is `moduleManifest.parse(…)` output, so every `key` is the branded `PermissionKey` by then and the literal union `defineOperations` needs is gone. Hand the **same array** to `definePermissions` as `keys` and the second description stops being unchecked: ```ts export const permissions = definePermissions({ modules: MODULES, roles: ROLES, entityGrants: ENTITY_GRANTS, keys: PERMISSIONS, }); ``` It throws when the module loads if the array and the modules disagree in either direction, naming the extra and missing keys — a key the modules declare but the array omits makes `defineOperations` reject a permission that really exists, and a key in the array that no module declares type-checks an operation against a permission nothing can ever grant. `PermissionKeysOf` reads the union back off the result for anywhere else that wants it. ### Narrowed permissions A bare `permission: 'customer:manage'` is a **node** check — anyone holding the key anywhere in the scope passes. An operation about *one* record says so, and there are three ways to say it, chosen by what the input carries: ```ts // One entity type, id in a field — the common case. permission: { key: 'facility:manage', entity: 'facility', idFrom: 'facilityId' }, // More than one type: the TYPE comes from a field too (#890). The admissible types // are read off that field's own schema — z.enum(['workorder', 'protocol']) — so the // set is stated once and cannot drift from a second list. permission: { key: 'workorder:read', entityFrom: 'entityType', idFrom: 'entityId' }, // A whole EntityRef the caller supplies — the engine case (#896). The engine narrows // to a noun in no registry it can see (absence checks against Meridian's employee), // so it names the FIELD carrying the ref. A dotted path reaches one level in. permission: { key: 'absence:read', refFrom: 'subject.ref' }, ``` The three are **mutually exclusive at compile time**: `entity` and `entityFrom` cannot appear together, and `refFrom` admits neither of them nor `idFrom` — a ref already carries both halves, so a second opinion beside it is a type error rather than a tie-break. When the id is genuinely not in the input (`set-item-done` takes an item and checks the *list* it sits on), say `resolved: ''` in place of `idFrom` — still not a node check, and honest that the handler finds the entity itself. An open `z.string()` behind `entityFrom` compiles, but the conformance kit reports the operation as undrivable rather than guessing a type. ### Paged reads A list operation declares `paged`, and its `output` then carries the **entry** shape — the platform supplies the envelope. There are two halves, and which one you write depends on whether the kernel can compose your query. #### The kernel composes it — `over` The normal case: the walk runs over one declared entity's table. Name the entity and the columns a caller may choose from, and the kernel builds the `WHERE`, the `ORDER BY`, the keyset comparison, the `LIMIT` and the matching `COUNT` — **and provisions the indexes behind them**, which is why this lives in the kernel and not in a query helper. ```ts 'acme/list-customers': { summary: 'Customers, newest first', permission: 'customer:read', output: entities.customer.fields, // the ENTRY, not an array paged: { over: { entity: 'customer', sortable: ['created_at', 'name'], // [0] is the default; ?sort= picks filterable: ['status'], // ?status=… , and an index behind it }, order: 'desc', total: true, }, http: { method: 'GET', path: '/customers' }, }, ``` Your handler asks `ctx.page` for the rows and keeps the projection: ```ts const page = ctx.page('customer', { ...input, total: true }); return mapPage(page, (c) => ({ ...c, facilities: facilitiesOf(ctx, c.id) })); ``` `mapPage` re-shapes the entries and leaves the walk alone — the cursor and the total survive it. Note what the page also bought you: that `facilitiesOf` call now runs once per customer **on the page**, where an unbounded list ran it once per customer in the scope. Add the manifest fragment once, derived rather than written: ```ts lists: listsDeclaredBy(acmeOperations, acmeEntities), ``` #### You compose it — `sortKey` {#compose-sortkey} Some reads cannot be kernel-composed, and saying so is not a failure — it is how the declaration stays honest. A read over a **kernel table** (`_substrat_outbox`), one whose `WHERE` is a **correlated subquery**, and one whose visibility is a **per-row permission walk** all fall here. Declare the entry field your cursor walks and build the query yourself: ```ts paged: { sortKey: 'article' }, ``` ```ts const limit = listLimitOf(input.limit); const rows = input.cursor ? ctx.sql.query('SELECT * FROM acme_prices WHERE article > ? ORDER BY article LIMIT ?', [input.cursor, limit]) : ctx.sql.query('SELECT * FROM acme_prices ORDER BY article LIMIT ?', [limit]); return pageOf(rows, limit, (row) => row.article); ``` For the permission-walk case, `pageVisible` over-fetches and advances the cursor by the last row **examined** — so rows the walk rejects still move it forward. Its pages may come back short, and a short page does **not** end the walk: only the absence of a `Link` header does. For the third case — a list the handler has **already folded in memory**, because the read is a projection, a correlated subquery or a join the kernel cannot compose — `pageOverFold(rows, input, key)` cuts the page off the folded result and hands back the same `Page`. It does not make the read bounded at the database; it bounds what crosses the seam and gives the caller a cursor, which is what lets a screen stop asking for the whole table. A read that grows without limit still wants `paged.over` and an index. **The cursor key must be unique and ordered the same way the SQL is.** A keyset walk resumes at "the row after this key", so a key that repeats makes the walk skip the rest of the tie or return it twice. `created_at` is the trap: it comes from `ctx.now()`, which is stable for the whole invocation, so every row written by one operation carries the identical instant. Where the ordering column is not unique, make the cursor the **pair** the `ORDER BY` already uses and join it with `CURSOR_FIELD_SEPARATOR` — a separator chosen because it cannot occur in an ISO instant, a ULID or a key: ```ts paged: { sortKey: 'occurredAt' }, ``` ```ts return pageOverFold(entries, input, (e) => `${e.occurredAt}${CURSOR_FIELD_SEPARATOR}${e.id}`); ``` `engines/metering` walks `(occurredAt, id)` and `(from, id)` that way. Spell the separator through the constant, never inline at the call site: the two ends of one cursor cannot then drift apart, and it appears in a search for "cursor". #### What follows from the declaration - the **handler's return type** becomes `Page` — or `CountedPage` with `total: true` — so declaring `paged` and returning a bare array does not compile; - **`limit` / `cursor` / `order` / `sort` are supplied by the platform**, not declared per operation, so the default page size and the `LIST_PAGE_MAX` ceiling are true of every paged read. A request above the ceiling is refused, not silently capped; - the **emitted OpenAPI** grows those parameters, an enum of your `sortable` columns, and one query parameter per `filterable` column; - the **HTTP body is still the entries array** — the walk rides in `Link` and `X-Total-Count` headers (#829), so adopting `paged` does not break a client. Filters are **equality only**. Ranges, `IN`, `LIKE` and boolean composition are where a filter vocabulary turns into a query language; a read that needs more than equality is an operation with its own name and its own arguments. A cursor is only valid for the sort that issued it. **Follow the `Link` header** — it carries the sort and every filter — rather than assembling `?cursor=…&sort=…` yourself. `total` is opt-in because a keyset page cannot produce one for free: it costs a second query per request, and it counts the **same filter** the page ran under. Say `true` where a screen renders `1–20 of 340`. See [What a good API looks like](https://substrat.net/concepts/api-design.md#lists-are-pages-not-dumps) for why it is keyset rather than offset. K-41 records why the kernel owns the composition — the index behind a declared filter is the part contracts could not have built. ## What the compiler checks Every one of these is a compile error, not a lint: - `parents`, `primaryKey`, `key` and `erasable` name fields and entities that exist - entity-pointing positions name a **pointable** entity — one identified by a single column - `permission` names a **declared** key — a typo becomes a *"Did you mean"* suggestion - an operation carries `permission` **or** `narrows: { reason }` — never both, never neither - a narrowed permission is **one** of `{ entity, idFrom }`, `{ entityFrom, idFrom }` or `{ refFrom }` — `entity` beside `entityFrom`, or either beside `refFrom`, does not compile - every `{var}` in an `http` path names a real input field - **`entityIdFrom` names a field of that operation's `output`** — for a mutation writing a *child*, the event is usually about the *parent*, so the id field and the entity differ - **`paged.sortKey` names a field of that operation's `output`** — same join, same reason: a cursor has to name something the entry actually carries - **`paged.over.sortable` and `filterable` name columns of the named entity** — a typo is a compile error listing the columns that do exist, and the entity must be *pointable*, since a keyset walk needs one column to break ties on - **a bare `z.array()` output with no `paged`** is refused when the module loads: a list read that answers with the whole table is unbounded by construction - **`concurrency.over` names the entity the operation `emits` about** — a version is the ULID of the last event about the entity, so a guarded write that announces nothing would leave both writers' `If-Match` passing and both commits landing; refused at module load - **a field-bag update declares `concurrency`** — an input with one required field naming the row and every other field optional over that entity's own columns is the shape that loses updates, and one with no `concurrency` is refused at module load, the same way a bare-array list output is - `piiClass` is required, and anything other than `'none'` requires a `subjectId`, because an erasure has to be keyable - a `payload` cannot carry a field the entity marks `erasable` — immutable events are the one place in a scope an erasure cannot reach That last check resolves through `emits.entity`, so it is exact: a `name` marked erasable on `customer` does not wrongly refuse an event about an `office` carrying its own `name`. ## Composing engines An engine exports its entity registry and its published row schemas. Import them; never retype an engine's shape. The registries today: `workorderEntities`, `protocolEntities`, `bookingEntities`, `meteringEntities` (`metering-meter`, `metering-entry`, `metering-period`) and `invitesEntities`. ```ts import { protocolEntities, protocolInstanceRow } from '@substrat-run/engine-protocol'; import { workOrder, workorderEntities } from '@substrat-run/engine-workorder'; export const operations = defineOperations(entities, PERMISSIONS, [ protocolEntities, workorderEntities, ])({ 'acme/open-job': { summary: 'Open a job and the work order it wraps', permission: 'customer:manage', input: z.object({ siteId: z.string() }), output: workOrder, // the engine's PUBLISHED type emits: { entity: 'workorder', /* the engine's entity */ … }, }, }); ``` Composing an engine means being gated by the engine's **keys**, not only your own — `acme/open-job` above may check `workorder:read`, and the work order engine is what declares that key, describes it and owns its meaning. The manifest says so with `manifestOperations` (#889): descriptions are supplied, the key *set* is derived from what the operations check, and a key another module owns is listed under `checksDeclaredElsewhere` rather than restated: ```ts export const manifest = moduleManifest.parse({ id: '@acme/vertical', version: '0.1.0', kernelContract: '^0.0.1', migrations: { journalDir: './migrations', compatibleFrom: '0.1.0' }, ...manifestOperations(operations, { permissions: { 'customer:manage': 'Open jobs and manage customers' }, checksDeclaredElsewhere: { 'workorder:read': '@substrat-run/engine-workorder' }, }), ...manifestEntities(entities, {}), }); ``` Both directions are errors at load: a key some operation checks that is neither described nor listed elsewhere, and a `checksDeclaredElsewhere` entry no operation checks any more. Without this the two options were both wrong — restate the engine's key (two modules declaring one key, the prose free to drift) or declare a key of your own that the handler does not check, which is how Callout's timeline came to tell a technician they could not read what they read every day (#865). Two things to know: **A row is not a return.** `workOrder` is what an operation returns; `workorderRow` is what the engine stores, and they differ — the row carries `facility_type`/`facility_id` as two snake_case columns where the published type carries one `EntityRef` in camelCase. Return the published one. **Some engines are composed by event, not by call.** Check whether the engine exports in-scope functions. `engine-invoicing` exports none — you *emit*, its consumers build the invoice basis, and you read it back through its operations or by consuming its events. See [Modules & the manifest](https://substrat.net/concepts/modules.md). ## Binding the handlers `OperationImpl` derives the handler map your operations require, and `satisfies` is the drift detector: ```ts export const operations = { … } satisfies OperationImpl; ``` Change a declared return and `tsc` names the exact method whose handler no longer agrees. An operation declared and not implemented — or implemented and not declared — is an error too. ## The manifest, derived ```ts export const manifest = moduleManifest.parse({ …, permissions: permissionsUsedBy(operations).map(/* … */), events: { emits: eventsEmittedBy(operations), consumes: [] }, ...manifestEntities(entities, { engines: [protocolEntities], attachmentTargets: [{ entityType: 'customer', readPermission: 'customer:read' }], relations: [{ entityType: 'protocol', parentType: 'customer' }], searchables: [{ entityType: 'customer', fields: ['name', 'number'] }], }), }); ``` `entityRelations` is **derived** from each entity's `parents` — local edges are never written twice. `relations` is only for edges involving a composed engine's entity, and both sides are checked against local ∪ engine names. `searchables` is derived in the other direction: you name the entity and the fields, and the helper carries the entity's **table and id column** across from the registry, because the kernel cannot build an index without them and you should not have to say where a customer lives twice. A field the entity does not have is a compile error, and so is a composite-keyed entity — a search hit is one id, and a table keyed by `(customer, year, month)` has none to return. See [Reads & scaling](https://substrat.net/concepts/reads.md#finding-a-row-by-what-someone-typed). ## From entities to tables {#emit-tables} An entity registry already describes a schema. Writing that schema again by hand as SQL is a *second description* of the same rows — and second descriptions drift, invisibly, until a query returns `undefined` for a column somebody renamed on one side. [`@substrat-run/model-emit`](https://substrat.net/reference/model-emit.md) derives the second from the first: ```ts import { emitTables } from '@substrat-run/model-emit'; import { entities } from './spec/model.js'; const sql = emitTables(entities); // CREATE TABLE acme_customers ( // id TEXT PRIMARY KEY NOT NULL, // number TEXT NOT NULL, // name TEXT NOT NULL, // created_at TEXT NOT NULL, // UNIQUE (number) // ); ``` Three things about it are worth knowing here, because they are consequences of how the model is declared: - **It is stricter than what you would have written.** The primary key becomes `TEXT PRIMARY KEY **NOT NULL**` — in SQLite a non-INTEGER primary key does not imply `NOT NULL`, so the hand-written version accepts a NULL id. Every hand-written `vertical_*` table in this repo had that hole; the emitter cannot produce it, and it refuses a nullable key column for the same reason. A composite `primaryKey` emits as a table-level `PRIMARY KEY (a, b)`, in declaration order — that order is also the index its columns are searched by. - **It refuses rather than guesses.** A Zod shape it cannot map to a column throws, naming the field. `z.boolean()` is refused outright for a *row* (SQLite stores 0/1 — declare `z.number()` and keep the row type honest); it stays right for an operation's *input*. A column that genuinely holds a document is declared as `jsonColumn('a reason')`, and a bare `z.unknown()` remains an error, so deliberately-opaque and not-yet-modelled stay distinguishable. - **It emits a schema, not a history.** `journalColumns`, `journalUniques` and `journalPrimaryKeys` replay an existing migration journal so a test can hold your registry and your journal to each other, and `planMigration` says what *one* new entry would have to contain — with a **derived** version number, because declaring a version is declaring a fact a diff already knows. It refuses anything that would rewrite history or lose data. ## `model.json` `emitModel(entities)` renders the registry to deterministic JSON, checked in and re-emitted by `pnpm lint:model --check` — the same shape as the permission and API checkpoints, so a changed table or a moved parent edge appears in the pull-request diff. It is the artifact for consumers that **must not execute your code** (a hosted console drawing your model) or that want diffability rather than validators (a breaking-change classifier). An engine's `model.json` also opens with a top-level `version`: `emitModel(entities, { version })` carries the engine's `manifest.version` verbatim, which versions the manifest **shape** rather than the package and is bumped only when that shape changes. The checked-in artifact is the field's reader, so a bump appears in the same diff as the change it announces. A vertical passes no `version` and its artifact carries none. It is **not** the input for a code generator. `z.toJSONSchema` keeps declarative constraints and drops programmatic ones — a `.refine()` and a `.brand()` vanish without trace — so a generator reading the JSON would emit validators weaker than you declared. A generator reads the TypeScript, where the Zod objects are live. ## See also - [`@substrat-run/model-emit`](https://substrat.net/reference/model-emit.md) — the DDL emitter, the journal reader, and the migration planner in full - [Modules & the manifest](https://substrat.net/concepts/modules.md) — what a module registers - [Permissions](https://substrat.net/concepts/permissions.md) — keys, roles and the proof walk - [Events & audit](https://substrat.net/concepts/events.md) — fat payloads, `piiClass`, and the spine --- # Lifecycles Source: https://substrat.net/concepts/lifecycle.md An entity with a `status` column has a state machine. Before #844 that machine was written down twice: once as the column's enum, and once as guards scattered through the operations that move it. ```ts // the enum, in entities.ts status: z.enum(['planned', 'in_progress', 'completed', 'closed']), // the machine, in index.ts — six call sites, held to that enum by nothing requireStatus(row, 'planned', 'in_progress'); ``` A **lifecycle** is that machine, declared once, beside the entities and operations it already draws on. ```ts import { defineLifecycles } from '@substrat-run/contracts'; export const workorderLifecycles = defineLifecycles( workorderEntities, workorderOperations, )({ workorder: { field: 'status', initial: 'planned', states: { planned: { on: { 'workorder/start': 'in_progress' }, allow: ['workorder/assign', 'workorder/report-time', 'workorder/report-material'], }, in_progress: { on: { 'workorder/complete': 'completed' }, allow: ['workorder/report-time', 'workorder/report-material'], extensible: true, }, completed: { on: { 'workorder/close': 'closed' } }, closed: { terminal: true }, }, }, }); ``` ## `on` and `allow` are different things `on` is an **edge**: the operation moves the entity to the named state. `allow` is a **precondition**: the operation is legal in this state and changes nothing. The distinction is not cosmetic, and it came from counting. Of the nine `requireState` call sites in the booking engine, several gate operations that move no state at all — attaching a note to a reservation checks that it is `held` or `confirmed` and leaves it exactly where it was. A format with only edges would have described the majority of those guards as transitions, and the emitted diagram would have grown a self-loop for every note anyone can write. **Absence means "not governed", never "forbidden".** `workorder/get` appears nowhere above because no state gates a read. A lifecycle describes which mutations a state admits; an operation it has never heard of is simply not its business. ## What the compiler checks The declaration is checked against the two things it names, which is the whole reason it is TypeScript rather than a schema file: | You write | It is checked against | |---|---| | `field: 'status'` | the entity's own fields | | a state name | the values that field can hold | | `initial` | the declared states | | an edge target | the declared states | | an operation id | the module's declared operations | Two of those bite in both directions. A state the column cannot hold is refused — and so is a value the column *can* hold that the machine has never heard of, which is where an entity goes to get stuck. Add a fifth value to the enum and forget the machine, and the build fails. > **Tip — Why one check runs at runtime** > > A `states` object carrying a key the enum does not have compiles clean however the > constraint is written — TypeScript applies no excess-property check when a value satisfies a > generic constraint. That check therefore runs at module load, where it can actually bite. A > check that reads like it works and does not is worse than an absent one. ## What a lifecycle cannot say {#what-a-lifecycle-cannot-say} The omissions are the design. There are no actions, no effects, no `context`, no parallel regions, no timers and no expression language. An edge names the operation that performs it and stops. The operation keeps its body, its permission check, its writes and its events. The moment an edge can carry a condition, this is BPMN in TypeScript — [the tarpit](https://substrat.net/guide/comparisons.md) the platform named in the same breath as adopting durable execution. **Durable execution is a separate concern.** Retries, sleeps and fan-out are the outbox and the sweeper, not this. A lifecycle says which states exist and which operation moves between them. It never says *when*, and it never runs anything. **Guards are not declared here either.** A guard is wired in the manifest as `{ before, predicate, config }` and evaluated by the kernel inside the guarded operation's own transaction. Every edge names its operation, so a guard on the operation already *is* a guard on the edge — the emitters join the two rather than making anyone write it twice. ## Substates A state marked `extensible: true` admits vertical substates: `in_progress` refines into `awaiting_parts` or `pending_customer_approval` without the engine learning either word. Transitions *within* an engine state are the vertical's; transitions *between* engine states stay the engine's, so no substate path can skip `completed`. An invariant-bearing state declares nothing. `completed` and `closed` carry billing consequences, and the absence of the flag is how they say so. ## What it is used for **Enforcement.** `assertTransition` replaces every hand-written guard and throws the platform's own `conflict` with `reason: 'invalid_transition'` — which is also how two demos that were throwing a bare `Error`, and returning a 500 where every engine returns a 409, came back onto the contract. **Review.** `pnpm lint:model` re-emits the machine into `model.json`, and CI re-emits with `--check`. A redirected edge or a state that stops admitting substates has to appear in a PR diff, the same way [`lint:permissions`](https://substrat.net/concepts/permissions.md) makes a widened role appear in one. Widening a state machine is no less consequential than widening a role, and until now it was a one-line change to an `if`. **Derivation.** The machine emits to an [XState v5](https://stately.ai/docs) config — states, edges, nested substates, `type: 'final'`, and guards joined in from the manifest. That is a one-way emit for diagrams and for a test oracle: XState's `transition()` is a pure function, so it can confirm the emitted machine and the engine's own guard agree on every state/event pair without XState ever leaving devDependencies. > **Warning — The emitted machine is not a source** > > Editing it in a visual editor and reading it back would make that editor a second authoring > surface. Anything checked in belongs behind a `--check` re-emit, so an edit goes red rather > than becoming the truth. ## Adopting one 1. Declare it in a file that imports the entities and the operations — not in `entities.ts`, which the operations already import and which would close the loop into a cycle. 2. Replace each hand-written guard with a call naming the **verb** rather than re-deriving the set of states that permits it. 3. Export an emitted model so `lint:model` picks it up: ```ts export const workorderModel = emitModel(workorderEntities, { lifecycles: workorderLifecycles }); ``` Adopted: `engine-workorder`, `engine-booking`, `engine-protocol`, `engine-invoicing`, `demos/manyfold`, `demos/shop`. Booking is worth reading as the harder example: seven states, three operations that are `allow` rather than edges, a state reachable by lapse rather than by transition, and a transition performed by composition. It needed no addition to the format. Protocol is the worked example of the section below: it gates content mutation, not just transitions, and each of its refusals carries its own reason — `content_frozen`, `wrong_status`, `already_voided`. Routing those through `assertTransition` would flatten three useful answers into one vaguer one, so it asks the declaration with `transitionFor` at all seven call sites and keeps its own prose. One is deliberately not adopted: - **`engine-absence`** has a machine (`requested → approved | rejected | cancelled`) and no entity to hang it on: its registry declares `absence_leave_types` and nothing else, so `absence_requests` is not a declared entity. Registering it comes first. ## When the module has a better reason than "invalid transition" `assertTransition` is the default. Where a module's own refusal says more, ask the declaration the *legality* question with `transitionFor` and keep your own reason: ```ts if (!transitionFor(invoicingLifecycles.underlag, underlag.status, 'invoicing/export')) { throw conflict('immutable_after_export', `underlag ${underlag.number} is '${underlag.status}' …`); } ``` The declaration stays load-bearing — it is still the single description of what is legal — and the caller still hears the invariant that actually stopped them. --- # Modules & the manifest Source: https://substrat.net/concepts/modules.md Engines and verticals join a scope host the same way: as **modules**. A module is one registration object bundling a manifest, migrations, operations, the schemas and preconditions the host enforces around them, and event consumers: ```ts import { operationInputsOf, operationConcurrencyOf } from '@substrat-run/contracts'; import type { ModuleRegistration } from '@substrat-run/kernel'; const registration: ModuleRegistration = { manifest, // self-describing metadata (validated Zod document) migrations, // ordered SQL, journaled per module, applied lazily per scope operations, // 'workorder/create' → handler operationInputs: operationInputsOf(workorderOperations), // name → input schema operationConcurrency: operationConcurrencyOf(workorderOperations), // name → If-Match target consumers, // 'workorder.completed' → handler }; host.registerModule(registration); ``` The two maps are derived from the declared operation surface rather than written a second time, and both are **optional** — see [the parse the host owns](#the-parse-the-host-owns) for what their absence means. ## The manifest The manifest is what makes a module **self-describing** — to the kernel that loads it, to the app shell that renders it, and to the agents that build on it. It's a Zod-validated document (`moduleManifest` in `@substrat-run/contracts`): ```ts export const workorderManifest = moduleManifest.parse({ id: '@substrat-run/engine-workorder', version: '0.0.1', kernelContract: '^0.0.1', // semver range of the kernel API it targets permissions: [ { key: 'workorder:create', description: 'Create work orders' }, // ... ], events: { emits: [{ type: 'workorder.created', schemaVersion: 1 } /* ... */], consumes: [], }, migrations: { journalDir: './migrations', compatibleFrom: '0.0.1' }, attachmentTargets: [{ entityType: 'workorder', readPermission: 'workorder:read' }], entityRelations: [{ entityType: 'workorder', parentType: 'facility' }], entitlementKey: 'workorder', envSpec: [ // optional: config the deployment must provide { key: 'WEBHOOK_SECRET', description: 'Signing secret for inbound webhooks', secret: true }, ], ui: { /* routes, nav, entityViews, widgets */ }, }); ``` Field by field: | Field | What it declares | Who consumes it | |---|---|---| | `id`, `version`, `kernelContract` | identity + the kernel API range this module targets | host, tooling | | `permissions` | every key the module checks, with a description | permission review (the human-readable diff), admin UI, agents | | `events.emits` / `events.consumes` | the module's event contract, schema-versioned | host wiring, compatibility checks, agents | | `migrations` | journal location and `compatibleFrom` — the oldest schema this code tolerates (the skew window) | migration runner | | `attachmentTargets` | entity types that accept attachments (documents, comments), and which permission gates reading them | kernel attachment services | | `entityRelations` | parent edges (`workorder → facility`) that permission flows along | the [permission evaluator](https://substrat.net/concepts/permissions.md) | | `entitlementKey` | the SKU flag that gates loading this module for a tenant | entitlements / billing | | `ownerGrants` | permissions a fresh install's owner holds on day one (marketplace install); scope-provisioning verticals only | dashboard install, registry | | `entitlements` | the full entitlement set an install grants (own + composed); absent ⇒ derive from `entitlementKey` | entitlements, registry | | `provides` / `requires` | named capabilities this vertical offers (`oidc-issuer`) or delegates to — wired tenant-side via the connection store | capability wiring, registry | | `envSpec` | declared environment variables (label, description, placeholder, `required`, `secret`) a deployment must provide | host/console config forms — carried on the registry (see below) | | `guards` | manifest-declared operation pre-conditions: a named predicate the kernel runs inside the operation's transaction, before the handler (a throw blocks it) | kernel | | `schedules` | recurring work — operations the platform invokes on every live scope of this vertical, on a cadence, under a system actor (a date-triggered business rule) | the platform sweep ([recurring work](#recurring-work-schedules)) | | `freshness` | freshness expectations — event types this module expects to keep arriving, and how many hours of silence is too many (the alert no error can raise) | the platform sweep ([freshness expectations](#freshness-expectations-freshness)), the dashboard's app page | | `withdraws` | operation names whose default binding this module suppresses — the name stops resolving, so a vertical can re-offer the transition behind its own guarded operation | kernel operation resolver | | `searchables` | entity types and fields to index for search — the kernel derives a per-scope FTS5 index and the triggers that maintain it | kernel ([Reads & scaling](https://substrat.net/concepts/reads.md#finding-a-row-by-what-someone-typed)) | | `api` | path to the emitted OpenAPI for the module's HTTP surface, if any | tooling / SDK generation | | `ui` | routes, nav items, entity views, widgets — permission-keyed, composed into the vertical's app at build time | app shell | | `ui.settingsPanels` | permission-keyed settings screens the shell mounts for the app | app shell | Every field past `entitlementKey` is **optional and additive** (decision 28): a manifest that omits them still parses, and adding one never breaks an existing module. ## Two manifests: authored in TypeScript, transported as JSON Worth being precise, because "manifest" names two things. The **module manifest** above is **TypeScript** — a typed object literal that `moduleManifest.parse(...)` validates. That `parse` is a correctness gate (a malformed manifest throws at load), *not* a sign you author JSON: you get full types, autocomplete, and refactoring across every field, and the keys in `permissions` are the same branded `PermissionKey`s your operations check. JSON appears in exactly one place — the **deploy manifest**, the envelope `substrat push` assembles and POSTs to the control plane. It's JSON because it crosses a process and trust boundary (the builder's CLI → the platform), and the same `deployManifest` schema re-parses it on the far side. So the shape you *edit* is typed TypeScript; the "one big JSON that gets parsed" is only the wire form, and only at the boundary where a wire form is unavoidable. **The permission surface travels in that envelope.** The keys and descriptions, role templates, and entity-grant *shapes* a vertical declares are carried in the deploy manifest as a `registry`, content-hashed as `digests.permission` (D-39). It's derived from the same declared surface the host registers (`MODULES` + `ROLES` + `ENTITY_GRANTS`), so it cannot drift from what is enforced, and `PERMISSIONS.md` is the human-readable render of that same surface — the review artifact for the permission checkpoint. Admission consumes it as a real permission diff between two versions; any tenant-facing permissions view reads it back. The runtime grant table is deliberately *not* in it: minted capability grants are scope-local tuples, reached only through the admin-query RPC. Two fields deserve special mention: - **`ui.entityViews`** is the cross-engine rendering mechanism: a module that stores an opaque `EntityRef` can render the entity's card by looking up the view registered for its `entityType` — no imports between engines. - **`entitlementKey`** is how the module system turns commercial: enabling an engine for a tenant is flipping an entitlement, and the kernel refuses to load what isn't entitled. ## Declared environment (`envSpec`) A vertical **opts in** to a configuration surface by declaring `envSpec` — the environment variables a deployment must provide, each self-describing: ```ts envSpec: [ { key: 'PUBLIC_ORIGIN', label: 'Issuer origin', description: 'The public URL of this app.', placeholder: 'https://app.example.com', required: true, secret: false, group: 'General' }, { key: 'ADMIN_PASSWORD', label: 'Admin password', description: 'Bootstrap admin password.', placeholder: 'at least 8 characters', required: false, secret: true, group: 'Bootstrap' }, ] ``` It's optional and additive — a vertical that declares nothing has no config surface. `secret: true` marks a value that is **write-only** in any UI (masked, never echoed back) and delivered as a secret; `group` sections the form; `required` is validated before deploy. The spec **rides the registry**: when a vertical is registered (`registerVertical`), its `envSpec` is stored alongside its slug. So a host or console can render a config form for **any** registered vertical — a bundled builtin or a pushed builder vertical — without loading its code. That is what makes opt-in a single edit in the manifest: declare `envSpec`, and the [Dashboard](https://substrat.net/platform/dashboard.md) shows a settings form for the app automatically. It evolves with the manifest — re-registering a vertical refreshes its spec. For a **pushed** vertical, `substrat push` reads `envSpec` from the vertical's `package.json` `substrat` block (the same static, code-free source it reads `slug`/`name` from) and carries it in the deploy manifest — the CLI never loads the built module, so the declaration must be readable as data at push time. > **Tip — Delivery depends on the app's shape** > > A **standalone** app (its own worker script) receives these as worker secrets/vars at deploy. > A **hosted** vertical (one script serving many tenants' scopes) can't use per-app worker > secrets — all its scopes share one script — so it takes per-tenant values through the > per-scope config it reads at runtime. ## Recurring work (`schedules`) Some business rules have no caller but the passage of time: a contract that becomes active on its start date, a leave request that can no longer be approved once it has already begun. The operation is ordinary — idempotent, permission-checked — it just needs something to invoke it on a cadence. A vertical declares that in `schedules`: ```ts schedules: [ { operation: 'hr/expire-stale-requests', // a registered module/verb operation cadence: { everyMinutes: 1440 }, // once a day permissions: ['absence:approve'], // what the operation checks // input: { … } // optional static input, re-parsed by the op }, ], ``` The [platform sweep](https://substrat.net/concepts/platform.md#scheduled-work) enumerates every live scope of the vertical and invokes the operation on each due one. Two things make it safe to run unattended: - **It is attributed to the schedule, never a person.** The invocation runs under a **system actor** — the emitted events read as `{ system: '@your/module' }`, not as whichever admin happened to be the last human to touch the data. Modelling a nightly job as a signed-in user is exactly the audit-trail laundering this exists to avoid. - **`ctx.check` is still the only gate.** The `permissions` a schedule declares are granted to the module's system principal when a scope is provisioned, so the operation's own `assertAllowed(await ctx.check(…))` resolves the same way it does for any caller — a schedule can do exactly what it declares and no more. Those permissions appear in the vertical's [`PERMISSIONS.md`](https://substrat.net/concepts/permissions.md#from-declaration-to-enforcement) under a *Scheduled work* section, so widening what a schedule may do lands in the reviewed diff. Revoking that grant for one tenant turns the schedule off for them — no special "disabled" flag, the operation just fails its own check closed. `cadence` is a floor, not a guarantee of exact timing: a schedule fires no more often than `everyMinutes`, and the sweep is what actually runs it (typically every couple of minutes), so sub-sweep cadences round up. It is optional and additive like every field past `entitlementKey` — a vertical that declares none has no recurring work. > **Tip — Where it runs** > > The sweep must run in the vertical's **own** runtime, where its modules and scope data > live — a node server calls `startPlatformSweeper` at boot, a Cloudflare deployment arms > a `PlatformSweeperDO` alarm. See [the platform sweep](https://substrat.net/concepts/platform.md#scheduled-work). ## Freshness expectations (`freshness`) A schedule is work that should *happen*; a freshness expectation is an event that should *keep arriving*. Some failures raise no error at all — a connector poll that quietly stops finding anything, an upstream that went silent, a webhook nobody re-registered — and the only symptom is that a scope which used to see a `receipt.landed` every day has not seen one for a while. A vertical declares what "a while" is in `freshness`: ```ts freshness: [ { eventType: 'receipt.landed', within: { hours: 24 } }, ], ``` Each entry names an event type and a window. The [platform sweep](https://substrat.net/concepts/platform.md#scheduled-work) judges every expectation against the scope's **own outbox** on each pass: the newest matching event is inside the window (`ok`), outside it (`failed`), or has never been observed (`skipped`). Verdicts are recorded as `freshness` sweep-run rows, rendered beside schedule health on the [dashboard](https://substrat.net/platform/dashboard.md)'s app page, and visible to staff on the [console](https://substrat.net/platform/console.md)'s Sweeps page. A row is written when the verdict *changes* and on an hourly heartbeat, not on every pass, so a long green run does not drown the one red row. Two rules are held at parse time rather than left to the reader: - **The window is in hours, deliberately.** `within.hours` is a positive integer, and there is no minutes form: a freshness window under an hour is a schedule wearing a disguise, and the declaration should read the way the alert will ("no `receipt.landed` in 26h"). - **The event type must be one this module emits or consumes.** `moduleManifest.parse` refuses an `eventType` that is in neither `events.emits` nor `events.consumes`: an expectation on a type that can never arrive would read as permanently stale forever, a typo becoming a permanent red pill. A vertical watching an engine's events lists the type in `consumes`, which star topology already requires of it. The refusal lands at push and at registration, where the message is readable. Unlike a schedule, a freshness expectation needs **no permission**: it invokes nothing, and reading the scope's own outbox is not a grant. It is optional and additive like every field past `entitlementKey` — a vertical that declares none has no freshness verdicts, and every earlier manifest still parses. ## Migrations Migrations are plain SQL, ordered and uniquely versioned per module: ```ts migrations: [ { version: '0001-init', sql: `CREATE TABLE workorder_orders ( ... );` }, ] ``` Semantics: - **Applied lazily per scope**, inside the scope's serialization domain, journaled in `_substrat_migrations` per (module, version). No global migration step; a scope migrates when it wakes. - **Skew is a normal state.** With thousands of scopes, some will run the old schema for a window. `compatibleFrom` declares the oldest schema version the module's code tolerates; a reconciliation sweep wakes stragglers before the window closes. - **Migrations are a human checkpoint.** They're deliberately plain SQL so review is review, not archaeology. ## Operations, consumers, and in-scope functions - **`operations`** — the module's invokable surface, namespaced (`'workorder/create'`). Each default binding starts with its own permission check. - **`operationInputs`** — name → the Zod schema the host parses an invocation's input against, before the guards and the handler see it. - **`operationConcurrency`** — name → the entity whose version an `If-Match` is compared against, and the input field carrying its id. The host compares, between `BEGIN` and the guards; a precondition a handler evaluates is a precondition a handler can forget. - **`consumers`** — event handlers keyed by event type; the types must appear in `manifest.events.consumes`. Idempotency required (at-least-once delivery). - **In-scope functions** — plain exports (not registered anywhere) that a vertical's own operations can call to compose engine behavior in the same transaction. See [Operations & the scope host](https://substrat.net/concepts/scope-host.md#in-scope-functions-vs-registered-operations). ### The parse the host owns `operationInputs` is where **"parse, don't trust" is kept, rather than in every handler**. The host parses an invocation against the named schema before the guards and the handler run, on every path in — HTTP, in-process `invoke`, a seed, a schedule — so a handler receives a value that has already been validated and does not parse again. ```ts operations: { 'rally/book': bookOp, /* … */ }, operationInputs: operationInputsOf(rallyOperations), ``` The map is derived from the declared operation surface, never written a second time: a declared `input` that the handler was supposed to re-parse is the same schema stated twice, and across the fleet the two drifted — rally declared 32 inputs and parsed 2. Both maps are optional on the interface, and the two directions are not symmetric: - **A name in the map that no operation binds is an error.** It is a schema enforcing nothing while reading as coverage. - **A bound operation with no entry is allowed**, and means what it always meant — nothing was declared to parse. So a module that declares Zod inputs but omits the map has compile-time types and no runtime check anywhere, with nothing to say so. ## Attachment contracts and opaque refs The kernel owns no entities, so everything generic binds to an opaque reference: ```ts type EntityRef = { entityType: string; entityId: string }; ``` Attachment contracts (documents, comments, activity, custom fields) attach to an `EntityRef`; `attachmentTargets` declares which types accept them and which permission gates access; `visibility` (`'internal' | 'customer'`) classifies every attachment item so customer-portal exposure is a total, mandatory decision — like `piiClass`, a classification only works if it was never optional. --- # What a good API looks like Source: https://substrat.net/concepts/api-design.md Substrat is opinionated about API shape on purpose. Not because there is one true REST, but because the alternative is that every vertical re-decides pagination, error bodies, time and identifiers — and then a fleet of them disagrees, forever, in ways that only surface when someone tries to write one client against two. The goal of this page is a specific one: **a well-shaped API should be what falls out of the defaults, not what a careful team remembers to do.** Where a convention is enforced mechanically, that is said plainly. Where it is still a convention, that is said too. > **Info — Status** > > Shipping today: the operation spine, boundary parsing, value types, the context clock, > lost-update safety, request idempotency, additive evolution, the generated OpenAPI > document, and the **error model** — every surface answers `application/problem+json`. The > **pagination** convention ships in `contracts` and is adopted across the platform's own > surfaces, but not yet by every engine and vertical. Nothing on this page is aspirational > without saying so. ## The shape of one operation Everything below is a variation on one idea, so it is worth seeing the whole thing once: ```ts 'callout/create-workorder': { summary: 'Open a work order against a facility', permission: 'facility:manage', input: z.object({ facilityId: z.string(), description: z.string().min(1), priority: z.enum(['normal', 'urgent']).optional(), }), output: workOrder, http: { method: 'POST', path: '/workorders' }, } ``` That declaration is not documentation *about* the operation. It **is** the operation's contract: the same Zod objects validate the request at runtime, type the handler at compile time, and generate the OpenAPI document served at `/openapi.json`. There is no second place where the truth is written down, so there is no second place for it to drift. ## The defaults ### 1. One operation, one permission, one event Every operation's first line is its permission check: ```ts assertAllowed(await ctx.check('facility:manage')); ``` Every mutation emits a **fat** event — one carrying enough payload that a consumer never needs to read back across a module boundary to understand what happened. Together these give you two things most systems retrofit badly: an authorization story that cannot be bypassed by adding a route, and an audit trail that is a byproduct of doing the work rather than a feature someone remembered. How much of that is mechanism rather than discipline is worth being precise about, because the honest answer is "most, not all": - **Declaring the permission is a compile error to skip.** An operation carries either a `permission` or a `narrows` with a stated reason — never both, and never neither. An entity-narrowed check must say what it narrows to, in one of three forms: `entity` + `idFrom` (one type, id in a field), `entityFrom` + `idFrom` (the type comes from a field too, its admissible values read off that field's `z.enum`), or `refFrom` (a whole `EntityRef` the caller supplies — the engine case). Naming a field that does not exist does not compile, and neither does mixing the forms: `entity` beside `refFrom` is a type error, not a tie-break. See [Narrowed permissions](https://substrat.net/concepts/model.md#narrowed-permissions). - **The permission surface is re-emitted by CI.** `pnpm lint:permissions --check` renders each vertical's `PERMISSIONS.md` from the same objects the code uses, so a widened role cannot merge without appearing in the pull-request diff. - **The handler's `assertAllowed` line is still a convention.** `tools/boundary-lint.mjs` enforces the data, network, spine-write and star-topology boundaries — it does not check that a handler calls its declared permission. That gap is closed by the declaration and by review, not by a linter. ### 2. Parse, don't trust Input crosses the boundary through a Zod schema or it does not cross. Not "validate the suspicious fields" — parse the whole input into a typed value, once, at the edge. Everything downstream is then working with a value the type system already believes in. ### 3. Values that survive the wire - **Money is a decimal string plus a currency**, never a float, and arithmetic goes through the shared helpers. See [Money](https://substrat.net/concepts/money.md). - **Identifiers are ULIDs.** Sortable by creation time, generated without coordination, and — usefully — they double as pagination cursors. - **Instants are ISO 8601 with an offset**, stamped once and never re-derived. The rule underneath all three: a value should mean the same thing after a JSON round trip as it did before one. Floats and naive local timestamps both fail that test. ### 4. Lists are pages, not dumps A list endpoint that returns everything is a bug with a delay on it. It passes review, it passes tests, and then one tenant's table gets large. The platform convention is **keyset pagination** — a cursor over the list's own sort key, never an offset. The request carries the walk in the query string, and the response carries it in **headers**, so the body stays the list it always was: ```http GET /api/customers?limit=20&cursor=01J8Z3K7Q9WRT0P ``` ```http 200 OK Link: ; rel="next" X-Total-Count: 340 [ … ] ``` The `Link` is [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288), the same header GitHub serves, and it hands the client a URL to **follow** rather than one to assemble — so the filters and the page size travel with it and cannot be dropped by accident. Its absence is how the walk ends: no `rel="next"`, no further request. `X-Total-Count` appears only for a list that asked for a total. **Why headers and not a `{ entries, nextCursor }` body.** Because the body is a published contract and the walk is not. Wrapping the body renames a live endpoint's response — `[…]` becomes `{ "entries": […] }` — so adopting paging broke every consumer a vertical could not see, and the rational move was to leave an unbounded list unbounded, which is the opposite of the point. It also could not be done at all for a list whose published shape was a bare array: a body cannot be an array and an object at once. In headers, adopting paging changes nothing a client was already reading. A caveat that follows from the choice: a browser client on a **different origin** cannot read `Link` or `X-Total-Count` unless the server lists them in `Access-Control-Expose-Headers` — and the symptom is not an error, it is a list that looks like it has exactly one page. `PAGE_EXPOSED_HEADERS` in `@substrat-run/contracts` is the list to expose. **Inside the platform, a page is still a value.** `ctx`-side and `stub.invoke` callers get `Page` — `{ entries, nextCursor }` — because an operation is transport-agnostic and a test, a seed or another operation has no HTTP response to read a header off. The handler returns `pageOf(...)`; the HTTP mount projects it. That is also why the platform's own control-plane API still answers with the envelope in the body: its only consumers are the console and dashboard, versioned and deployed with it, so it has no migration problem to solve and no unknown client to protect. There is **one** way to page, not two. Page numbers and `offset` are not offered, and that is a decision rather than an omission: on live data rows shift between requests, so an offset window silently skips and duplicates rows. A cursor names a *position in the ordering* instead of a count of rows that have scrolled past, so a row inserted mid-walk cannot push another onto a page you already read. The cost is honest and worth stating: keyset gives you *next*, not *jump to page 7*. A page number is not recoverable from a cursor, and asking for one is usually a sign the screen wants a report rather than a list. **A total count is available, and is opt-in.** Keyset cannot produce one for free — that is the trade for correctness under concurrent writes — so a total is a second query per request. Business software asks for it constantly (a table of work orders with no `1–20 of 340` reads as broken), so the platform supports it rather than pretending nobody needs it. It just declines to charge every list for it: ```ts paged: { sortKey: 'id', total: true }, ``` The handler then returns `countedPageOf(...)` instead of `pageOf(...)`, and the compiler holds it to that; the total reaches the client as `X-Total-Count`. Two things to know about the number: it counts the **filtered** set — the same `WHERE` the page ran under, never the table, which is the mistake that looks right until a second list exists — and it is a snapshot, so rows written mid-walk can make page one's total disagree with the rows eventually seen. That is inherent to counting a moving set, not something to design around. Two defaults worth knowing: HTTP list reads **default to a page** (20, capped at 200), because egress is where an ever-growing table has to stop being a dump. Kernel-side reads default to **unbounded**, because internal callers — provisioning, catalogs, sweeps — mean "everything", and a silent cap there would let them mistake a page for the whole set. **A search is not a list, and does not go on the list endpoint.** Once a list is paged, a client filtering the page it received is searching the first page only — so a picker over a large table needs a real index, not a `q` parameter bolted onto the list. Give it its own route: ```http GET /api/customers/search?q=ander&limit=10 ``` The two reads have genuinely different contracts. A list is ordered by a declared sort key and paged by a cursor over it; a search is ordered by *relevance*, which has no stable sort key and therefore no honest cursor — paging a ranked result set reorders rows, and rows go missing or double. So a search is **capped** and says so, and the caller narrows the term rather than paging. One endpoint cannot carry both contracts, which is why every API that has tried ends up with two anyway. `/customers/search` does not collide with `/customers/{id}`: a static segment is registered ahead of its parameter sibling, the same rule OpenAPI uses to resolve a concrete path before a templated one. Declaring the operation is what gets you that ordering — a hand-written route table has to do it by hand. How the index itself is declared and read is in [Reads & scaling](https://substrat.net/concepts/reads.md#finding-a-row-by-what-someone-typed). #### Declaring it An operation declares `paged` and its `output` carries the **entry** shape; the platform supplies the envelope, the query parameters and the handler's return type: ```ts 'acme/list-customers': { output: entities.customer.fields, // the ENTRY, not an array paged: { sortKey: 'id', order: 'desc', total: true }, http: { method: 'GET', path: '/customers' }, }, ``` `sortKey` is compile-checked against that entry's fields, and the handler is typed to return `Page` — so declaring `paged` and returning a bare array does not compile. See [The model](https://substrat.net/concepts/model.md#paged-reads) for the handler side. > **Tip — Not adopted — enforced** > > A list operation that declares no `paged` and answers with a bare `z.array()` is **refused > when the module loads**, so an unbounded list read cannot exist in a registered module. The > declared filter/sort vocabulary (`paged.over` — the kernel-composed half) is what keeps a > cursor correct under a caller-chosen sort, and the engines and demos declare it; the > control plane, dashboard and console use the same convention on their own reads. > **Tip — The one place offset survives** > > The console's **scope-table browser** pages with `limit`/`offset`, deliberately. It is > random access into a table for a human reading rows — "jump to 5,000" is the actual > requirement, and drift between requests is not a correctness problem there. It is a > debugging surface, not a product API, and it is the exception that has to justify itself. ### 5. Failures are data An error is part of your API surface, not an accident that happens to it. `500 Something went wrong` is unactionable for a human and worse for an agent; `validation_failed on field 'email'` can be recovered from without a person reading a log. The shape is [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) `problem+json` with a closed code taxonomy: ```json { "type": "https://substrat.net/errors/permission-denied", "title": "Permission denied", "status": 403, "detail": "permission denied: customer:manage", "code": "permission_denied", "permission": "customer:manage" } ``` The codes are a small closed set — `unauthenticated`, `permission_denied`, `forbidden`, `not_found`, `conflict`, `validation_failed`, `precondition_failed`, `rate_limited`, `unavailable`, `internal` — because an open set is a suggestion. A module narrows one with a `reason` slug it owns rather than inventing a code, which is the same boundary discipline engines follow everywhere else. One rule that is not a detail: **`internal` never carries a message.** An unrecognised throw is by definition one nobody reviewed for what it discloses, and a multi-tenant surface is the wrong place to find out. **A body with no `code` is telling you something.** When a transport is relaying a status it did not raise — a throw nobody typed, a downstream vertical's own refusal — the body is RFC 9457's `about:blank` form: the status, the message, and no taxonomy entry. Inventing a code there would put our vocabulary on a failure we cannot describe, and a client switching on `code` would match it. So the absence is the honest answer, and it is also the signal that a throw site is still untyped. You get all of this by throwing `substratError('conflict', 'the cart is empty', { reason: 'cart_empty' })` and letting `problemResponse` render it. `code` is the platform's, `reason` is yours. > **Info — The deprecated duplicate** > > Every body also carries `error`, a copy of `detail`, because every SPA in this repo read > `{ error }` before the model landed. It exists for one migration window and then goes — > read `detail`. ### 6. Time comes from the context An operation should not read the wall clock. It should ask its context what time it is, so that tests can freeze it and replay means something: ```ts const now = ctx.now(); ``` This looks like a nicety until you try to test anything with a window in it — leave balances, metering periods, booking availability — and find that the only way to assert the interesting case is to wait for it. A host takes a `clock`, so a scenario hands in `frozenClock` or `manualClock` and asserts the elapsed case exactly, instead of sleeping. It is a mechanism rather than a convention: `new Date()` and `Date.now()` in module code are `boundary-lint` **R6** violations, the same class of ban as a `node:*` import. Code that must read the real wall clock — a JWT whose `exp` a remote server judges — opts out in a reviewable `boundary-lint-allow R6` block. The value is also stable for the whole invocation, which is a second thing worth having: the rows a write leaves and the events announcing them agree about when they happened. ### 7. Writes are safe to retry A client that times out does not know whether the work happened. It retries, and there is a second work order. Agents make this acute rather than novel: they retry more aggressively than people, and they do it faster. The client sends a token it chose. The same token on the retry returns the first response instead of doing the work again: ```http POST /api/workorders Idempotency-Key: dispatch-4471 ``` ```http 200 OK Idempotency-Replayed: true { "id": "01J8Z…", … } ``` **Nothing is declared, and that is the difference from §7b.** Every operation on an unsafe method honours the header, because a retried write creating a second entity is a hazard on all of them — where a lost update is a hazard on the field-bag shape alone, which is why `concurrency` is opt-in and this is not. The client opts in by sending a key; the server never requires one. A `GET` is already idempotent, so the header is not honoured there: doing so would mean serving a recorded *body* for a read, which is a cache with none of a cache's rules. **The recording is written inside the operation's own transaction**, and three properties fall out of that one placement rather than from mechanisms of their own: - **A failed request is retried, not replayed.** The operation threw, the transaction rolled back, and the recording went with it. There is nothing to find, so the retry executes — correctly, because nothing happened the first time. Recording failures would have meant deciding which of them are permanent, and a retry after a 500 is the most ordinary thing a client does. - **A replayed response describes work that actually committed.** There is no window in which the recording exists and the rows it describes do not. - **A concurrent retry cannot slip past.** Invocations serialise per scope, so the second request takes its turn after the first has committed. Every other implementation of this needs an in-flight state and a "still processing" 409; this one does not. **A key names one request.** Send the same key with a different body and it is refused with `409 conflict`, never served the earlier response — a client that received an answer to a question it did not ask will act on it. Two deliberate identical writes are two writes, and a client that wanted one sends one key. A key is also scoped to the caller who sent it: two clients will both choose `1`, and a lookup that crossed that boundary would replay one principal's response to another. Two limits, stated rather than discovered: - **A replay is not a fresh authorization.** The recorded response is returned without running the handler, and the permission check lives inside the handler. What bounds it is that a caller can only ever reach responses it received itself, and that a key is remembered for **24 hours**. The alternative — re-running the operation so the permission can be re-checked — is the duplicate execution the key exists to prevent. - **A response too large to record (over 128 KiB) refuses its replay** with `409` rather than executing again. Fail closed: an error the caller can act on beats doing the work twice, and the original did complete. **Opting out is a line someone wrote.** An operation whose response must not be recorded — a freshly minted secret, a one-time token — says so, and the platform then refuses the header rather than silently storing the response or silently executing twice: ```ts 'acme/mint-token': { idempotency: false, // the response is a credential; do not record it … } ``` Opt-out rather than opt-in because the two read differently in a diff: a missing opt-in is invisible, while `idempotency: false` is something a reviewer can ask about. It is the same reasoning `narrows` applies to a permission that is deliberately not node-level — state the exception, never the rule. > **Tip — Not the event spine's idempotency** > > Consumers have been required-idempotent since the beginning, and the contract suite checks > it: a consumer may see an event more than once and must settle to the same state. That is > the spine re-delivering. This is a client re-sending. Different boundary, different actor, > no relationship beyond the word. ### 7b. A read-modify-write says what it is writing over Two people open the same record, both save, and the second write silently destroys the first. There is no error, no log line, and nobody notices until the data is gone. This is the oldest bug in multi-user software and the one hardest to see afterwards — the row looks fine, it is just wrong. An operation that is read-modify-write declares `concurrency`: ```ts 'callout/update-facility': { permission: { key: 'facility:manage', entity: 'facility', idFrom: 'facilityId' }, input: z.object({ facilityId: z.string(), name: z.string().optional(), address: z.string().optional(), accessNote: z.string().optional(), }), concurrency: { over: 'facility', idFrom: 'facilityId' }, emits: { entity: 'facility', entityIdFrom: 'id', type: 'callout.facility-updated', … }, http: { method: 'PATCH', path: '/facilities/{facilityId}' }, } ``` Declared once, it does three things. Every response carries the entity's version as an `ETag`. An unsafe method compares the caller's `If-Match` against that version **inside the operation's transaction** and refuses a stale one with `412 precondition_failed`. And the generated browser client remembers the tag from a read and sends it on the next write to the same entity, so an app writes no header code. **Opt-in, not blanket.** Most declared operations are command-shaped — `todo/rename-list` takes a name, not a whole entity it read and echoed back — and two concurrent renames do not lose an update. A mandatory precondition there is a forced round-trip guarding nothing. **But not left to memory.** The shape that *does* lose updates is visible in the model: one required field naming the row, every other field optional over that entity's own columns. An operation of that shape with no `concurrency` is refused at module load, the same way a bare-array list output with no `paged` is. > **Tip — Why the guarded operation must emit** > > An entity's version is the ULID of the last event about it — there is no version column > ([#901](https://github.com/substrat-run/substrat/issues/901)). So a guarded write that > announces nothing is *worse* than an unguarded one: both writers pass their `If-Match`, > neither moves the version, both commit, and both get a 200 with an `ETag` asserting the > write was serialised. `concurrency.over` is therefore compile-checked against the > operation's declared `emits`. > > Adopting this in Callout is what revealed that `create-facility` had never emitted at all — > so every facility it created had no version, and no conditional update against one could > ever have succeeded. Two smaller rules follow from the same reasoning: - **The 412 does not carry the current version.** Handing it back turns the obvious client fix into a blind retry that overwrites whatever caused the refusal. Re-reading is what gives a user something to merge. - **`ETag` must be in `Access-Control-Expose-Headers`** for a cross-origin browser client — `CONCURRENCY_EXPOSED_HEADERS` names it. Unexposed, the tag reads as `null`, no `If-Match` is ever sent, and the protection switches itself off silently in exactly the deployment shape where two editors are most likely. ### 8. Surfaces only grow Once shipped, an operation's surface evolves **additively**: - new inputs are optional, with behavior-preserving defaults; - emitted event payload fields are frozen — renaming, removing or retyping one means a `schemaVersion` bump, which today is a **replace**: consumer dispatch keys on the event type alone, so a dual-emit deprecation window is not available. See [the invoicing engine's `underlag-exported` v2 note](https://substrat.net/engines/invoicing/events.md#versioning); - permission keys are never renamed. This is the rule that makes a fleet of independently-deployed verticals survivable. It is also the rule that makes *deciding conventions early* matter so much: an additive-only system is one where the cost of a wrong default compounds. ### 9. The document is generated, and CI diffs it Every vertical serves `/openapi.json` and a rendered reference at `/api/docs`, both built from the operation catalog — the same Zod schemas the handlers parse. The emitted document is checked in, and CI re-emits it to fail on drift. The reason to care: a hand-written spec is a description of what someone believed the API did on the day they wrote it. A generated one cannot be wrong without the code being wrong. ## What "well-architected" means here Underneath the specifics there is one idea, and it is the same one the [three-layer rule](https://substrat.net/concepts/modules.md) expresses: **prefer mechanisms to conventions.** A convention is a thing a careful person remembers. A mechanism is a thing a careless person cannot get wrong. Substrat's bet is that most of what people call architecture discipline is actually the absence of mechanism — so the platform spends its complexity budget on compile-time joins, boundary lints, checked-in artifacts that CI re-emits, and contract tests every adapter must pass, rather than on documents describing how to behave. Two things follow that are worth stating, because they cut against instinct: - **A good default is one you cannot silently opt out of.** Pagination that is available is pagination that half the endpoints skip. - **Two human checkpoints stay human on purpose** — a migration diff and a permission diff. CI going red is what makes the reading unskippable; it is not itself the approval. Some judgments should not be automated away, and knowing which ones is part of the design. ## The defaults at a glance | Default | Shape | Status | |---|---|---| | Permission declared | `permission` or `narrows` + reason | Compile error to omit | | Permission checked first | `assertAllowed(await ctx.check(…))` | Convention + review | | Fat events on mutation | payload complete for consumers | Convention + review | | Boundary parsing | Zod at the edge | Shipped | | Money | decimal string + currency | Shipped | | Identifiers | ULID | Shipped | | Pagination | keyset cursor, declared with `paged`; entries in the body, the walk in `Link` / `X-Total-Count` | Shipped; a bare-array list output with no `paged` is refused at module load | | Lost-update safety | `concurrency` + `If-Match` / `ETag`, 412 on a stale tag | Shipped; compile error to omit on a field-bag update | | Errors | RFC 9457 problem+json, closed codes | Shipped; `about:blank` where a status is all we have | | Clock | `ctx.now()` | Shipped; `new Date()` in module code is a lint error | | Idempotent writes | `Idempotency-Key`, response replayed for 24h | Shipped; honoured on every write, `idempotency: false` to opt out | | Evolution | additive only | Convention + review | | API document | generated, CI-diffed | Shipped | --- # The MCP surface Source: https://substrat.net/concepts/mcp.md A vertical whose routes come from `mountOperations` serves an [MCP](https://modelcontextprotocol.io) endpoint at `/api/mcp`, and **writes nothing to get it** — one tool per operation, derived from the same declarations the route table is. That is the whole prerequisite, and it is worth stating plainly: this rides on the **derived** route table. A vertical that still hand-writes its routes has no operations catalog to render, so it has no tool list either — it gets the endpoint by adopting `mountOperations`, not by configuring anything here. ```http POST https://your-vertical.example/api/mcp Authorization: Bearer ``` The point is not that MCP is fashionable. It is that a vertical already declares everything a tool needs, so the surface is a **derivation** rather than a thing you maintain. There is no tool registry to keep in step with your routes, and no second file that can disagree with your model. ## `http` is the declaration An operation that declares `http` becomes a tool. One that does not, does not. ```ts 'ticket0/configure-desk': { summary: 'Change the desk’s settings', // → the tool description permission: 'desk:configure', // → the check that still runs input: z.object({ greeting: z.string().optional() }), // → the tool's inputSchema http: { method: 'PATCH', path: '/desk' }, // → this is what makes it a tool } ``` That is the same boundary the [route table](https://substrat.net/concepts/api-design.md) already draws. A composed engine's in-scope functions carry no `http` — the engine is entity-agnostic and does not own a URL shape — so they are not routes and they are not tools either. There is deliberately **no `mcp: true`**. A flag saying "also expose this" would restate a fact `http` already carries, and the two would part company the first time somebody renamed a route. ## Exposure is not authorization This is the property that lets the surface default to on. Every tool maps to a route that already exists, behind the same bearer verification and the same `assertAllowed(await ctx.check(…))` inside the operation. Anything an agent can do through MCP it could already do with `curl` and the same token. The endpoint is a new **rendering**, not a new door. Two consequences worth knowing, because they are what a hand-rolled MCP server usually gets wrong: - **Authentication is transport-level.** Every call to the endpoint needs a principal — the handshake included — so an anonymous client's *first* message gets an HTTP `401` carrying the challenge, which is what makes it start its authorization flow. Never reported as a tool failure, and never deferred to the second request. - **Authorization is in-band.** A refused permission comes back as a normal tool result with `isError: true`, so the agent reads *"you may not do that"* and picks something else. Returning a transport error there would make one refusal look like a broken session. ## Finding where to authenticate A 401 that only says "no" is a dead end: the client has no way to learn which issuer to talk to, and somebody has to paste a token in by hand. So the endpoint can publish [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728) protected-resource metadata and point its challenge at it. ```http 401 Unauthorized WWW-Authenticate: Bearer resource_metadata="https://desk.example/.well-known/oauth-protected-resource/api/mcp" ``` ```json { "resource": "https://desk.example/api/mcp", "authorization_servers": ["https://issuer.example"], "bearer_methods_supported": ["header"] } ``` **This is the one thing that cannot be derived**, and therefore the one line of wiring: ```ts import { authorizationServersOf } from '@substrat-run/vertical-auth'; mountApi(app, stub, async (c) => authorizationServersOf(await instanceConfig(c.env, nodeFor(c.req.raw, c.env)))); ``` The only thing that knows a vertical's issuer is its auth composition, and on a hosted install the issuer is *per-scope configuration* — one serving script answers for many desks, each with its own. So it is resolved per request rather than fixed at mount. Omit it and the endpoint still works; the challenge is a bare `Bearer` and a token is configured out of band. A challenge is never pointed at a document that is not served — naming a 404 costs the client a round trip and tells it less than the bare scheme did. An instance nobody has configured a login for publishes a document with **no** `authorization_servers` rather than an empty list: "this resource has no issuer" and "nobody has set this up yet" are different claims, and a client acts differently on them. ### What is deliberately not here **Client registration.** How a client obtains a `client_id` — dynamic registration ([RFC 7591](https://www.rfc-editor.org/rfc/rfc7591)), or a client-ID metadata document — is a question for the *authorization server*. A resource server validates an access token and has no opinion about who minted the client, so that work belongs at the issuer, not here. (The platform's stock issuer supports both — see [the auth server](https://substrat.net/concepts/identity.md#the-auth-server-a-full-oidc-provider-you-can-run).) **Audience validation** is already in place where it is configured: set `OIDC_AUDIENCE` (or `audience` on a delivered `substrat:auth`) and a token minted for another resource is rejected. Worth setting before you hand the URL to anyone. ## Paged reads A paged read's tool schema names `limit`, `cursor`, `order` and `sort` explicitly. Over HTTP those ride in the query string and the host supplies them. An MCP call has no query string, so a paged tool that did not name them would look to an agent like a list that returns everything — it would pull one page and report it as the whole table. That is a wrong answer with no error anywhere, which is the failure this platform works hardest to refuse. The ceiling behaves as it does on the wire: a `limit` above `LIST_PAGE_MAX` is **refused, not silently capped**, because a capped page is indistinguishable from the end of a walk. ## What a tool call cannot do yet Two HTTP affordances have no MCP equivalent, and a tool call behaves exactly like a wire call that omitted them — which is a legitimate call, not a skipped check: - **Optimistic concurrency.** A tool result carries no `ETag`, so an agent has nothing to echo back and a guarded operation runs with no precondition. Read-modify-write over MCP is therefore last-writer-wins. - **Idempotent retries.** There is no `Idempotency-Key`, and inventing one per call would defeat the point — a retry would mint a new key and write twice anyway. An agent that retries a failed write may write twice. Neither can be fixed by forwarding the POST's own headers: a batch carries several calls and one header cannot speak for all of them. Both want a tool-level convention — an `ifMatch` argument and a returned version — which is a design question, not an omission. ## The one knob, and it is optional ```ts 'ticket0/record-answer': { summary: 'Record an assistant answer and its token usage', mcp: false, // never a tool, for anyone http: { method: 'POST', path: '/turns' }, } ``` `mcp: false` is for operations that face the network because *a machine* posts to them — a connector's return path, a relay's ingest, a widget service's surface, a schedule's entry point. They are reachable over HTTP for good reason and are pure noise in an agent's tool list. The other half of the knob is a description: ```ts mcp: { description: 'Search the knowledge base. Use before answering any question.' } ``` `summary` is written for an API document — it answers *what is this*, where tool selection needs *when would I reach for this*. Say more only where the difference bites. A new vertical writes neither of these. ## What this does not solve **Tool count.** ticket0 routes 59 operations; marking its machine-facing ones leaves 46 tools, which is still a lot to put in front of a model. Two things will help, and it is worth being clear that neither is here yet: - **A permission filter** on `tools/list`, so a caller is not offered what they cannot call. This is a least-privilege floor rather than a curation mechanism — dramatic for a narrow principal, marginal for an admin. ticket0's `desk-admin` holds 16 of its 19 permission keys, so filtering would barely shorten *its* list. - **Per-consumer app scoping**, which is where a *coherent* toolset actually belongs. A triage agent and a reporting agent want different subsets of the same vertical while holding the same principal — and that is a fact about the consumer, which neither the model file nor the permission spine can express. Filtering `tools/list` is invisible to the protocol, so both drop in later without breaking a client. ## Turning it off ```ts mountOperations(app, myOperations, resolveStub, { mcp: false }); ``` Or configure it: ```ts mountOperations(app, myOperations, resolveStub, { mcp: { path: '/mcp', serverInfo: { name: 'ticket0', version: '1.4.0' } }, }); ``` The server name defaults to the module prefix your operations share (`ticket0/get-desk`, `ticket0/list-agents` ⇒ `ticket0`), so it cannot drift from what the tools are called. ## What the endpoint speaks Stateless [Streamable HTTP](https://modelcontextprotocol.io/specification/basic/transports): one `POST` per JSON-RPC message, answered as `application/json`. `GET` returns `405` — this server opens no server-to-client stream, because every tool is a request/response call into a scope and there is nothing to push. Protocol revisions are negotiated, newest first: `2025-06-18`, `2025-03-26`, `2024-11-05`. A client asking for one we do not implement is answered with ours rather than an echo of theirs, and decides for itself whether to continue. Implemented: `initialize`, `ping`, `tools/list`, `tools/call`. Resources and prompts are not — `tools` is the whole capability today. --- # Money Source: https://substrat.net/concepts/money.md Substrat ships **one money representation** and one sanctioned way to compute with it, because "every engine invents its own money handling" is how reconciliation dies. ## The representation ```ts import { money, moneyOf, type Money } from '@substrat-run/contracts'; type Money = { amount: MoneyAmount; // decimal string, up to 6 dp — never a float currency: CurrencyCode; // ISO 4217: 'SEK', 'NOK', 'EUR'… }; const price = moneyOf('1250.50', 'SEK'); ``` - **Decimal strings, never floats.** `0.1 + 0.2` problems cannot enter the system; amounts survive JSON round-trips exactly. - **Branded types.** A random string won't typecheck as a `MoneyAmount`; parse it. - **Multi-currency from day one.** Currency is part of the value, and mixing currencies throws. ## The arithmetic All computation happens on micro-units (6 decimal places, `bigint`) with half-up rounding at the 6th decimal — exact, deterministic, overflow-free: ```ts import { addMoney, mulMoney, addDecimal, mulDecimal, compareDecimal } from '@substrat-run/contracts'; addMoney(a, b); // Money + Money (throws on currency mismatch) mulMoney('2.5', hourlyRate); // quantity × unit price → Money addDecimal('10.1', '0.2'); // '10.3' — plain decimal strings compareDecimal(a, b); // -1 | 0 | 1 ``` This is the **only** sanctioned way engines compute with money. If you find yourself calling `parseFloat` on an amount, stop. ## Why it's this strict The system's standing integrity guarantee is that **reported totals reconcile with transactional truth** — a reconciliation job continuously verifies that sums derived from the event stream match the balances in scope databases. That guarantee is only as good as the weakest arithmetic in any engine, which is why the arithmetic lives in `@substrat-run/contracts` and not in each engine's utils file. You can see the discipline in the engines: the work-order engine sums billable lines with `addMoney`; the invoicing engine stores `amount`/`currency` as separate columns and totals lines with `addDecimal`. Same primitives, same rounding, same results — reconcilable by construction. --- # What is an engine? Source: https://substrat.net/engines/index.md An **engine** is domain machinery shared across verticals but too domain-shaped for the kernel: work orders, invoicing, scheduling, ticketing, protocols/checklists. Engines are headless, versioned npm packages that register into a scope host as [modules](https://substrat.net/concepts/modules.md) — no UI framework, no HTTP server, no storage of their own beyond the tables their migrations create inside each scope. ## The division of labor **Engines own invariants.** State machines can't skip states. Time entries are append-only. An exported invoice basis is immutable. Every mutation emits an event. Every access passes a permission check. **Verticals own everything with a user's fingerprints on it.** Vocabulary, extra states and fields, triggers, pricing logic, screens, reports, industry content. The design test for the boundary: **if a vertical ever needs to fork an engine, the engine drew its line wrong.** A concrete example from the work-order engine: it has no price list and no pricing logic — pricing differs per business, so the *vertical* computes billable lines and hands them to `complete`. The engine enforces what must always hold (status transitions, append-only reporting, event emission), not what any particular business decides. ## Star topology: engines never talk to each other No engine imports or calls a sibling — enforced by review and by design. Engines compose through three kernel-mediated channels: 1. **Opaque refs** — an engine stores `(entityType, entityId)` without knowing what it is. The work-order engine binds orders to a `facility` ref it never dereferences. 2. **Events** — schema-versioned contracts on the spine. The invoicing engine consumes `workorder.completed` with **zero imports** from the work-order engine; it parses its own Zod view of the payload. 3. **Vertical-owned orchestration** — synchronous flows needing two engines are wired in the vertical, where the glue is visible and editable. Why: with *N* engines talking to the kernel there are *N* contracts to keep compatible; with engines talking to each other there are *N²*. Star topology keeps every engine independently versionable, licensable, and replaceable. The corollary: **if two engines need chatty synchronous communication, they are one engine drawn wrong.** That's why "work orders + time + material" is one engine, not three — time entries have no meaning outside their work order. ## What an engine is *not* "Engine" is a borrowed word, and every reader arrives with a different picture of it. Four contrasts to clear it up: - **Not an Odoo app** (or any "app on a platform"). An Odoo app bundles the whole vertical slice — ORM models, logic, views, vocabulary — and extends its siblings by inheritance (`_inherit`, cross-app foreign keys). An engine is the inverse: headless, no UI, no vocabulary, owning only invariants, and forbidden from importing a sibling. What Odoo puts in one app, Substrat splits across an engine (the invariants) and a vertical (everything with a user's fingerprints on it). If you ever want to *subclass* an engine, it drew its line wrong. - **Its real cousin is a [Medusa v2](https://docs.medusajs.com/learn/fundamentals/modules/isolation) module** — and that's no coincidence. Strict isolation, cross-module links instead of foreign keys, per-module migrations: Medusa converged on the same shape from e-commerce. Know Medusa modules and you already know engines — Substrat wraps them in nested multi-tenancy and runtime enforcement. - **Not a Rails engine or a plugin.** Those are code-organization conventions; nothing at runtime stops a plugin from reaching into the host's tables. An engine's boundaries are *enforced* — another module's tables are private, reachable only through its exported functions and events, and the `boundary-lint` check fails the build when you cross the line. - **Not a microservice.** No network hop, no separate deployment, no eventual consistency between engine and vertical. An engine runs in-process inside the scope, and a vertical composes its in-scope functions in the *same transaction* — `createWorkOrder(ctx, …)` and your own table write commit together or not at all. The isolation is architectural, not physical. - **Not a [connector](https://substrat.net/connectors/index.md).** An engine owns invariants and domain state *inside* a scope; a connector reaches a third-party service *outside* one. An engine never calls the network — that is precisely a connector's job. The two are different layers, and the clearest tell is that an engine has tables and permissions while a connector has neither. ## Anatomy of an engine package Every engine exports the same shape: ```ts export const PERM = { /* parsed permission keys */ }; export const engineManifest = moduleManifest.parse({ /* self-description */ }); export const engineMigrations = [ /* ordered SQL */ ]; // In-scope functions — composable from vertical operations, same transaction. // A by-call engine has these; a by-event engine deliberately has none (see below). export function createWorkOrder(ctx, input) { /* ... */ } // The full registration: manifest + migrations + default operation bindings export const engineModule: ModuleRegistration = { /* ... */ }; ``` Using an engine as-is is one line: ```ts host.registerModule(workorderModule); ``` Wrapping it with vertical logic means writing your own operation and calling the engine's exported in-scope functions — same transaction, same serialization domain, and the permission check becomes your responsibility: ```ts host.defineOperation('acme/create-order', async (ctx, input) => { assertAllowed(await ctx.check(PERM.create)); const order = createWorkOrder(ctx, mapToEngineInput(input)); // your own tables, in the same transaction return order; }); ``` ### Two composition modes That sample is the **by-call** shape, and it is not every engine's. An engine is composed one of two ways, and which one it is decides whether in-scope functions exist at all: | | **By call** | **By event** | |---|---|---| | Engines | work orders, bookings, protocols, absence, invites, metering | [invoicing](https://substrat.net/engines/invoicing/index.md) | | Operations are | thin — a permission check plus one exported function | the surface itself; the logic lives in them | | A vertical | wraps the exported functions in its own transaction | *emits* a fact its consumers pick up | | Reads results | from the function's return value | back through the engine's own operations. Its events announce that something changed and carry the change, not the whole entity, so a vertical projects them into a side table keyed by the engine's id and reads the operation when it needs the entity | | In-scope exports | yes, that is the whole point | **none, deliberately** — the engine stays the only writer of its rows, which is what keeps an invariant like immutable-after-export safe from a half-finished caller | The mode is a fact about an engine's exports, and the by-event one says so in its package header, so an absent in-scope surface reads as intent rather than as an omission. If you are integrating an engine and cannot find functions to call, check the mode before assuming they are missing. ## Engines today | Engine | Package | What it owns | |---|---|---| | [Work orders](https://substrat.net/engines/workorder/index.md) | `@substrat-run/engine-workorder` | the order state machine, append-only time & material reporting | | [Bookings](https://substrat.net/engines/booking/index.md) | `@substrat-run/engine-booking` | allocation against capacity over an interval — no double-booking, holds and their expiry | | [Invoicing](https://substrat.net/engines/invoicing/index.md) | `@substrat-run/engine-invoicing` | invoice-basis accumulation from billable events, immutability on export | | [Protocols](https://substrat.net/engines/protocol/index.md) | `@substrat-run/engine-protocol` | protocols/checklists with the sign → immutable invariant, verifiable content hash | | [Invites](https://substrat.net/engines/invites/index.md) | `@substrat-run/engine-invites` | single-use invitations to join an org with a role — invited → accepted; the plaintext identifier is never stored, the per-scope salted hash is | | [Absence](https://substrat.net/engines/absence/index.md) | `@substrat-run/engine-absence` | the append-only absence ledger over an opaque subject — balance as a fold, approval as the only mint for bookings, per-type floors | | [Metering](https://substrat.net/engines/metering/index.md) | `@substrat-run/engine-metering` | the billable-usage ledger — idempotent ingest, counters vs gauges, period close as frozen billing evidence; quantities, never prices | All seven are **product seeds** — small deliberately, hardened as real verticals consume them. Six were extracted from the demo verticals; metering is the one exception, noted below. The booking engine is the one **second invariant shape**: where a work order is a *state machine*, a reservation is *allocation against capacity over an interval*. It is also the clearest demonstration that the DO-per-scope choice buys something concrete — the engine contains no locking code at all, because a scope is a single writer. The protocol engine is the extraction proof itself: it was forced out of vertical code only when a *second* vertical (a bike shop's per-bike condition report) needed the same sign-immutability invariant in a different shape — and a *third*, [an HR vertical](https://substrat.net/guide/what-is-substrat.md#current-status), reuses it again for employee **onboarding checklists**, bound to an `employee` ref instead of a work order. Same engine, three shapes; that is the reuse thesis holding. The absence engine is the newest extraction, and the second run of the protocol engine's proof: its *append-only, a-correction-is-a-new-entry, current-value-is-a-fold* shape was written vertical-first inside the HR demo, and extracted only when a second consumer with a different shape — field-crew resource planning, where the subject is a plannable `resource` rather than an employee — forced the line. (The question of whether the generic entries-against-a-ref core belongs in the kernel was considered and settled: it stays engine-private until a non-absence consumer wants the raw ledger shape.) The metering engine is the honest exception to the extraction rule: it was built engine-first, against a named first consumer (the platform's own builder portal, whose AI turns must be billed) with the second — vertical AI features — in sight rather than in hand. The decision log carries that caveat rather than glossing it; the discipline it follows instead is a design document reviewed before code, and the same one-invariant-set smallness as its extracted siblings. Planned next, in the order verticals force them: **scheduling/dispatch** and **ticketing** (ärende). ## How these pages are organized Every engine documents itself the same way, in the same five pages. The shape is a contract: a page that has nothing to say is a finding, not an omission. | Page | Answers | |---|---| | **index** | *Is this a good match?* — what it owns, what it won't do, when to reach for it | | **Domain model & invariants** | the tables, and the rules the engine will not let you break | | **Operations, functions & permissions** | the callable surface, and which parts are composable | | **Events** | what it emits and consumes, payload contracts, versioning | | **Composing & extending** | using it from a vertical, configuration, the connector seam | Two conventions worth knowing before you read: **Engines have no endpoints.** They expose *operations* (named handlers invoked through a scope stub, each doing its own permission check) and *in-scope functions* (plain exports a vertical calls inside its own transaction, where the caller owns the check). Any HTTP surface is generated from the manifest's `api` field, never hand-written. The split between those two surfaces **is** the extension model, which is why every engine has a page for it. **Engines take no configuration.** `registerModule` accepts one frozen constant; there is no options object, no factory, and no `config` field on the manifest. When behaviour must vary, you compose (your own operation calling in-scope functions), declare (a guard predicate with its own kernel-opaque config), store (tenant content as data in the scope), or gate (the entitlement flag). *Configuration is dynamic; composition is code.* The Composing page of each engine says which of those applies, and admits where the engine doesn't yet allow it. --- # Work orders › Overview Source: https://substrat.net/engines/workorder/index.md `@substrat-run/engine-workorder` — one engine covering **work orders, time reporting, and material reporting**. One state machine, append-only reporting, and a billable snapshot frozen at completion. It deliberately knows nothing about pricing (the vertical's job) or invoicing (a sibling engine, reached only via events). ## At a glance | | | |---|---| | **Package** | `@substrat-run/engine-workorder` | | **Entitlement key** | `workorder` | | **Owns** | the order state machine, append-only time & material reporting, the billable snapshot | | **Emits** | 7 events, `workorder.created` → `workorder.closed` ([events](./events)) | | **Consumes** | nothing — it is a source, not a sink | | **Permissions** | 6 (`workorder:create` · `read` · `assign` · `report` · `complete` · `close`) | | **Status** | product seed (0.x) — surfaces change until the first vertical ships | ## What it owns - **The state machine cannot skip states.** `planned → in_progress → completed → closed`; invalid transitions throw. No caller can complete a planned order or restart a completed one. - **Time and material are append-only.** No update, no delete. Corrections are compensating entries, never silent edits — this is what makes the reporting trail trustworthy. - **Attribution comes from the ambient principal**, never from the input. - **Every mutation emits a fat event.** `workorder.completed` carries the full billable snapshot, so downstream consumers never query back. Details and the tables behind them: [Domain model & invariants](./model). ## What it will not do - **Pricing** — no price lists, no rates, no ROT/tax logic. Verticals price; the engine records quantities and freezes what it's handed. - **Invoicing** — it emits `workorder.completed`; what happens next is the [invoicing engine](https://substrat.net/engines/invoicing/index.md)'s business. - **Scheduling** — assignment is a field, not a calendar. Dispatch/scheduling is a future sibling engine. ## Is this a good match? | Reach for it when | Look elsewhere when | |---|---| | Work is dispatched as discrete jobs that move through fixed states | Work is continuous or has no meaningful lifecycle | | You need a defensible record of *who did what, when* | You only need a task list — a to-do table is cheaper | | Time and/or material get reported against the job | The billable unit isn't labour or parts | | The job's completion is a **business event** others react to | Nothing downstream cares that work finished | | Your pricing is yours and shouldn't live in shared machinery | You want the engine to price for you — it won't, by design | The clarifying question: **does your domain have a moment where work is finished and priced, and does anything care?** If yes, this engine owns that moment. If your answer is "we just track status", you want a table, not an engine. Note the scope call: work orders, time, and material are **one** engine, not three. Time entries have no meaning outside their work order, and two engines needing chatty synchronous talk are one engine drawn wrong. --- # Work orders › Domain model & invariants Source: https://substrat.net/engines/workorder/model.md ## The state machine **Declared**, in `engines/workorder/src/lifecycle.ts`, and emitted to `engines/workorder/model.json` — this is the first engine to adopt a [lifecycle](https://substrat.net/concepts/lifecycle.md). The table below is written from that declaration; the declaration is the source, and `pnpm lint:model --check` is what keeps this page from drifting away from it. **Diagram — the workorder state machine**, on `status`, starting at `planned`. - **The main run:** `planned` → *start* → `in_progress` → *complete* → `completed` → *close* → `closed` **Permitted without moving:** `in_progress` admits *report-material*, *report-time*; `planned` admits *assign*, *report-material*, *report-time*. **Terminal:** `closed`. An operation the machine has never heard of is not forbidden — it is not governed. Absence means "no state gates this", never "denied". Drawn from the engine’s declared lifecycle in model.json — the same artifact lint:model --check gates, so this picture cannot drift from the code. | Operation | Legal in | Moves to | Permission | |---|---|---|---| | `workorder/create` | — | `planned` (the declared `initial`) | `workorder:create` | | `workorder/assign` | `planned` | — | `workorder:assign` | | `workorder/start` | `planned` | `in_progress` | `workorder:report` | | `workorder/report-time` | `planned`, `in_progress` | — | `workorder:report` | | `workorder/report-material` | `planned`, `in_progress` | — | `workorder:report` | | `workorder/complete` | `in_progress` | `completed` | `workorder:complete` | | `workorder/close` | `completed` | `closed` | `workorder:close` | The rows with no target are `allow` entries — legal in that state, moving nothing. Assignment is a field, not a state, which is why `assign` leaves the order in `planned`. `in_progress` is the one state marked `extensible`, so a vertical may refine it with substates (`awaiting_parts`, `pending_customer_approval`). `completed` and `closed` carry billing consequences and admit none. `closed` is `terminal`. Invalid transitions throw a `conflict` carrying `reason: 'invalid_transition'` — a `completed` order cannot be started, a `planned` order cannot be completed. The engine owns this; no caller can skip a state. ## Tables Created by migration `0001-init`, inside each scope's own database: - **`workorder_orders`** — id, sequential `number` (per scope), `facility` and `customer` as opaque `EntityRef` columns, vertical-defined `kind`, title/description, status, assignment, provenance (`created_by`, `created_at`, `completed_at`). - **`workorder_time_entries`** — order, technician, decimal `hours`, note, `reported_at`. - **`workorder_material_lines`** — order, article, decimal `qty`, note, reporter, `reported_at`. Notice what's *absent*: no facility table, no customer table, no price columns. The engine references facilities and customers as opaque refs the vertical owns, and it records quantities, not prices — **quantities are facts, prices are business decisions.** ## The invariants 1. **The state machine cannot skip states** (above). 2. **Time and material are append-only.** There is no update or delete operation for reported entries. Corrections are a vertical-level concern (a compensating entry), never a silent edit. 3. **The reporter is the principal.** Time entries record `ctx.principal` as the technician; material lines record the reporter. Attribution comes from the ambient context, not from the input — a caller cannot claim someone else did the work. 4. **Every mutation emits a fat event** ([events](./events)). ## Completion: the pricing boundary `complete` is where the engine meets money, and the boundary is precise: **the vertical prices, the engine freezes.** ```ts await stub.invoke('workorder/complete', { orderId, billable: [ { article: 'TIME', description: 'Servicetekniker', qty: '2.5', unit: 'h', unitPrice: moneyOf('850', 'SEK'), lineTotal: moneyOf('2125', 'SEK'), sourceType: 'time', sourceId: timeEntryId, }, // ... ], }); ``` Each billable line carries **provenance** (`sourceType: 'time' | 'material'`, `sourceId`) back to the reported entry it prices. The engine validates the lines (Zod + [exact decimal arithmetic](https://substrat.net/concepts/money.md)), sums the total with `addMoney`, transitions the order, and emits `workorder.completed` with the full billable snapshot in the payload. The engine never *derives* a price from a reported entry — it has no rates to derive from. It checks that what it was handed is arithmetically sound, then makes it permanent. --- # Work orders › Operations & permissions Source: https://substrat.net/engines/workorder/surface.md An engine has **no endpoints**. It exposes two surfaces, and the difference between them is the whole extension model: - **Operations** — named handlers invoked through a scope stub. Each is a default binding that does its own permission check. - **In-scope functions** — plain exports a vertical calls *inside its own operation*, in the same transaction, where the vertical owns the permission check. HTTP is derived, never authored: a manifest may point at an emitted OpenAPI spec via its `api` field. Nothing in the engine speaks HTTP. ## Operations | Operation | Permission | Does | |---|---|---| | `workorder/get` | `workorder:read` (per-entity) | one order with its time and material | | `workorder/list` | `workorder:read` | a page of orders, optionally filtered by status | | `workorder/assign` | `workorder:assign` | assign a technician (order stays `planned`) | | `workorder/start` | `workorder:report` | `planned` → `in_progress` | | `workorder/report-time` | `workorder:report` | append a time entry | | `workorder/report-material` | `workorder:report` | append a material line | | `workorder/complete` | `workorder:complete` | freeze billable lines, `in_progress` → `completed` | | `workorder/close` | `workorder:close` | `completed` → `closed` | `workorder/get` checks per-entity (`ctx.check(PERM.read, orderRef(id))`), which is what makes the customer-portal walk work — see [Composing](./composing#portal-reads). > **Tip — There is no `workorder/create` operation — and that is the design** > > The engine registers no `create`. Creation is the in-scope function `createWorkOrder(ctx, …)` > only, because **the vertical must price and label the order first**: it arrives from a > felanmälan, a booking, a ticket. `demos/callout` reaches it through `callout/create-workorder`. > > The hole is deliberate and load-bearing — the engine owns the state machine, the vertical > owns vocabulary and pricing, and the engine leaves a gap exactly where the vertical belongs. > It's also why an engines-only scope can't do anything on its own: > *configuration is dynamic; composition is code.* ## In-scope functions ```ts import { createWorkOrder, completeWorkOrder, PERM } from '@substrat-run/engine-workorder'; ``` | Function | Backs | Notes | |---|---|---| | `createWorkOrder(ctx, input)` | *(no operation)* | the deliberate hole above | | `assignWorkOrder(ctx, input)` | `workorder/assign` | records the technician; the order stays `planned` | | `startWorkOrder(ctx, input)` | `workorder/start` | `planned` → `in_progress` | | `reportTime(ctx, input)` | `workorder/report-time` | appends a time entry, attributed to the acting principal | | `reportMaterial(ctx, input)` | `workorder/report-material` | appends a material line | | `completeWorkOrder(ctx, input)` | `workorder/complete` | validates + freezes billable lines; optional `currency` labels the total of a completion that has none (default `SEK`) | | `closeWorkOrder(ctx, input)` | `workorder/close` | | | `listOrders(ctx, page)` | `workorder/list` | a `Page` — `page` is the kernel's `PageParams` (`limit`, `sort`, `order`, `cursor`, `filters`, `total`), walked by `ctx.page`; with `total: true` the result is a `CountedPage` that also carries the filtered count | | `getWorkOrder(ctx, orderId)` | `workorder/get` | one order by id; throws on an id that names nothing | | `getReportedLines(ctx, orderId)` | `workorder/get` | time + material for an order | `listOrders` is **paged, not filtered by a positional `status?`**. The old `listOrders(ctx, status?)` returned every matching row with no bound and a hard-coded sort; it is gone rather than kept beside the paged read. Pass the status as a filter instead — `listOrders(ctx, { filters: { status: 'planned' } })` — and read one order with `getWorkOrder` rather than walking the list for it: once the list is a page, the row you want is simply not on page one. See [Lists are pages, not dumps](https://substrat.net/concepts/api-design.md#_4-lists-are-pages-not-dumps) for the walk. None of these check permissions — that is the caller's job, by design. Calling one from your own operation without `assertAllowed(await ctx.check(…))` first is a bug the linter won't catch for you. **What they return is parsed, not asserted.** Every value crossing back out of this engine goes through the schema the engine publishes — `workOrder`, `timeEntry`, `materialLine` — the same schema you point your own operation's `output` at. Engine surfaces evolve additively (D-28), but that rule is held by review; the parse is what makes the failure it prevents *loud*. A vertical compiled against 0.3 and running against 0.4, whose row shape moved, used to read a field that had become `null` and render it — now the read throws at the seam. Reads name their columns for the same reason: `SELECT *` would publish whatever the physical table happens to hold. Every operation is a thin binding of one of these — a permission check plus one call — so a vertical that wants to report time *and* touch its own tables in one transaction composes `reportTime` inside its own operation. The four reporting-side functions (`assignWorkOrder`, `startWorkOrder`, `reportTime`, `reportMaterial`) take exactly the input their operation declares and parse it on the way in. ## Permissions Declared in the manifest with descriptions — fuel for the [permission-review diff](https://substrat.net/concepts/permissions.md): | Key | Description | |---|---| | `workorder:create` | Create work orders | | `workorder:read` | Read work orders, time and material | | `workorder:assign` | Assign a technician | | `workorder:report` | Start work, report time and material | | `workorder:complete` | Complete a work order (with billable lines) | | `workorder:close` | Close a completed work order | `workorder:create` is declared even though no operation binds it: `createWorkOrder` is composed by verticals, which check this key themselves. Typical role shapes: a *technician* gets `read` + `report`; a *coordinator* adds `create` + `assign` + `complete`; closing (the bookkeeping-facing act) can be reserved for back-office. ## Entitlement `entitlementKey: 'workorder'`. A tenant that doesn't hold the flag can't resolve these operations — checked per invoke, fails closed. It's a binary SKU gate, not configuration; see [Composing](./composing#configuration). --- # Work orders › Events Source: https://substrat.net/engines/workorder/events.md The engine **emits 7 and consumes none** — it is a source, not a sink. Nothing upstream drives a work order; the vertical does, through operations. ```ts events: { emits: [ /* the 7 below */ ], consumes: [], } ``` ## Emitted | Event | v | piiClass | Payload | |---|---|---|---| | `workorder.created` | 1 | none | order id, number, facility, customer, kind, title | | `workorder.assigned` | 1 | pseudonymous | order id, technician | | `workorder.started` | 1 | none | order id | | `workorder.time-reported` | 1 | pseudonymous | order id, entry id, hours | | `workorder.material-reported` | 1 | none | order id, line id, article, qty | | `workorder.completed` | 1 | none | order id, number, facility, customer, **billable lines + total** | | `workorder.closed` | 1 | none | order id | ## `workorder.completed` — the contract that matters This is the engine's real public surface. It is a **fat event**: the full priced snapshot travels in the payload, so a consumer never needs a cross-module read. ```ts { orderId, number, facility: EntityRef, customer: EntityRef, billable: [{ article, description, qty, unit, unitPrice: Money, lineTotal: Money, sourceType: 'time' | 'material', sourceId }], total: Money, } ``` The [invoicing engine](https://substrat.net/engines/invoicing/index.md) consumes this with **zero imports** from here, parsing its own Zod view of the payload. That's star topology in one line: this engine has never heard of invoicing, and invoicing has never heard of this package. Because the payload is fat, prices are frozen at the moment of completion. If the vertical's price list changes tomorrow, yesterday's invoice basis doesn't. ## PII and erasure Events referencing a person carry `piiClass: 'pseudonymous'` and that person's `subjectId` (`workorder.assigned` → the technician; `workorder.time-reported` → the reporter). A GDPR erasure can then crypto-shred the person while the operational facts — hours were worked on this order — survive. ## Evolution rules Payload fields are **frozen once shipped**. New fields may be added; renaming, removing, or retyping one means a `schemaVersion` bump. Read the [invoicing engine's `underlag-exported` v2](https://substrat.net/engines/invoicing/events.md#versioning) before you bump anything — consumer dispatch keys on event **type alone**, so dual-emitting two versions delivers *both* to every consumer of that type. That constraint applies to these events too. --- # Work orders › Composing & extending Source: https://substrat.net/engines/workorder/composing.md ## Using it as-is ```ts host.registerModule(workorderModule); ``` That's the whole registration surface. `registerModule` takes exactly one argument, and it's a frozen constant. ## Wrapping it with vertical logic The registered operations are **default bindings**. For custom flows, call the exported in-scope functions from your own operation — same transaction, same serialization domain, and the permission check becomes yours: ```ts import { createWorkOrder, PERM } from '@substrat-run/engine-workorder'; host.defineOperation('acme/felanmalan-to-order', async (ctx, input) => { assertAllowed(await ctx.check(PERM.create)); ctx.sql.exec('UPDATE acme_tickets SET status = ? WHERE id = ?', ['converted', input.ticketId]); return createWorkOrder(ctx, { facility: ticketFacility(ctx, input.ticketId), customer: ticketCustomer(ctx, input.ticketId), kind: 'felanmalan', title: input.title, }); }); ``` The ticket update and the order creation commit together or not at all. `demos/callout/src/routes.ts` maps the seam precisely: `assign`/`start`/`close` go straight to the engine, while `create-workorder` and `complete-workorder` (which needs billable lines priced) route through `callout/*`. ## Configuration **There is none, and that's deliberate.** `ModuleRegistration` has five fields — `manifest`, `migrations`, `operations`, `consumers`, `predicates` — and none is config. There is no `createWorkorderModule({...})` factory and no `config` field on the manifest. An engine cannot be told anything at registration time. *Configuration is dynamic; composition is code.* When you need the engine to behave differently, you reach for one of these instead: | You want | Use | |---|---| | only part of the engine | `withdraws` in your manifest — suppress a default binding, re-offer it behind your own operation | | different behaviour around a transition | your own operation calling the in-scope function (above) | | a parameterised rule | a guard predicate with `guards[].config` — kernel-opaque, parsed by the predicate that owns it | | tenant-specific content | runtime data in your own tables | | the engine off entirely for a tenant | revoke the `workorder` entitlement | `entitlementKey` is a **binary SKU gate**, not a config knob: a tenant holds it or the operations don't resolve. It's checked per invoke and fails closed. One caveat — bare `defineOperation` glue carries no manifest and is **ungated**, so a vertical operation wrapping an in-scope function bypasses the entitlement check. ### Adding data to an order Never add a column upstream. A vertical needing extra fields on a work order adds **its own side table keyed by the engine's order id**. Another module's tables are private; the stable surface is entity ids, `EntityRef`s, and event payloads. ## Portal reads The engine declares the edge that makes entity-narrowed access work: ```ts entityRelations: [{ entityType: 'workorder', parentType: 'facility' }] ``` `createWorkOrder` records it with `ctx.link(orderRef, input.facility)`, so a [capability grant](https://substrat.net/concepts/permissions.md#capability-grants) on a facility reaches the work orders under it. A portal customer gets an entity-narrowed `workorder:read` on their own facility and `workorder/get` — which checks per-entity — does the rest. ## Reaching the outside world Nothing in this engine talks to anything external, and it never will: module code may not `fetch`, and connectors handle the outside world. The seam is `workorder.completed` on the event spine. A **connector** — a third-party capability living in the kernel-owned integrations hub, neither engine nor vertical — is what would carry it outward. > **Info — The seam is built; nothing consumes this event yet** > > The [connector seam](https://substrat.net/connectors/index.md) is real and running in production — `registerConnector` > binds a handler to an event type, the per-tenant connection store holds the credential sealed > at rest, delivery is at-least-once with retry and a dead letter, and a provider's callback > comes home through the inbound authority seam. The [Scrive connector](https://substrat.net/connectors/scrive.md) uses > all four. > > What is missing here is a **consumer**: nothing subscribes to `workorder.completed` today. This > seam is an opening nobody has taken yet, not a floor that hasn't been poured. --- # Bookings › Overview Source: https://substrat.net/engines/booking/index.md `@substrat-run/engine-booking` — reservations as **allocation against capacity over an interval**. A resource is held for a span of time, and concurrent allocations may never exceed that resource's capacity. It deliberately knows nothing about timezones, pricing, opening hours, or who is allowed to book — all of which are the vertical's. ## At a glance | | | |---|---| | **Package** | `@substrat-run/engine-booking` | | **Entitlement key** | `booking` | | **Owns** | the allocation invariant, the reservation state machine, holds and their expiry, append-only participants | | **Emits** | 12 events, `booking.held` → `booking.completed` ([events](./events)) | | **Consumes** | nothing — it is a source, not a sink | | **Permissions** | 8 (`booking:create` · `read` · `hold` · `confirm` · `cancel` · `move` · `complete` · `manage-resources`) | | **Status** | product seed (0.x) — surfaces change until the first vertical ships | ## What it owns - **No overallocation, ever.** For any resource, the sum of live allocations over any instant never exceeds its capacity. Exclusive booking is capacity `1`; capacity above 1 is for genuinely fungible pools (rental equipment, general-admission slots) where nobody cares *which* unit. - **Intervals are half-open** — `[startsAt, endsAt)`. A booking ending at 19:00 and one starting at 19:00 do not collide. Stated once, relied on everywhere. - **A hold is never permanent.** `held` requires an `expiresAt`, and expiry is *lazy*: a lapsed hold simply stops counting, with no sweeper to run and nothing to go wrong if one never runs. - **The state machine cannot skip.** `held → confirmed → in_service → completed`, plus `held → expired`, `{held | confirmed} → cancelled`, and `{confirmed | in_service} → completed | no_show`. Invalid transitions throw. - **Participants are append-only.** Leaving sets `left_at`; the row survives, so the record of who was on a booking stays intact. ### The property worth understanding **There is no locking code in this engine, and none is needed.** The allocation check is a plain read-then-write. It is correct because a scope is a single Durable Object — one serialization domain, one writer — so the read and the write that follows it never interleave with another transaction. That has a hard consequence for how you scope: **a resource's entire calendar must live in one scope.** Split one court's bookings across two scopes and you have silently reintroduced distributed locking, with nothing to tell you. SQLite has no exclusion constraint, so the guarantee comes from the serialization domain rather than from the database. ## What it will not do - **Timezones and calendar arithmetic.** The engine takes and compares absolute instants. "Every Tuesday at 19:00", opening hours, DST — all vertical. See [composing](./composing). - **Pricing.** It never learns what a slot costs. Duration is an input, not a multiplier. - **Policy of any kind** — cancellation windows, who may book, skill bands, membership. It knows only *fill target* and *deadline*. - **Field patches.** Mutations are named transitions. There is no `updateReservation`; rescheduling is [`move`](./surface), putting places on offer is [`open`](./surface), and participants change through join/leave. ## Is this a good match? Reach for it when something scarce is held for a span of time and double-booking is unacceptable: courts and pitches, salon chairs, workshop benches, meeting rooms, clinic slots, equipment rental, classes with a seat count. Reach for something else when the thing you are modelling has no interval (a queue, a ticket) or when "capacity" is really inventory that depletes rather than a slot that frees up again afterwards. --- # Bookings › Domain model & invariants Source: https://substrat.net/engines/booking/model.md ## The primitive > A **resource** held over a **time interval**, with the invariant that concurrent > allocations never exceed that resource's **capacity** over any overlapping interval. Two axes are easy to conflate and must not be: | Axis | Question | Padel court | Rental pool | |---|---|---|---| | **Resource capacity** | how many *concurrent reservations* fit? | `1` — held exclusively | `10` rackets | | **Participant fill target** | how many *people* are on this reservation? | `4` players | `1` | Four players on a court is **not** capacity 4. It is four participants on one reservation that exclusively holds a capacity-1 court. Capacity above 1 is only for fungible pools; when you care *which* unit, model separate resources. ## The state machine **Diagram — the reservation state machine**, on `state`, starting at `held`. - **The main run:** `held` → *confirm* → `confirmed` → *start* → `in_service` → *complete* → `completed` - **Leaves the run:** `confirmed` → *cancel* → `cancelled`. - **Leaves the run:** `confirmed` → *no-show* → `no_show`. - **Leaves the run:** `held` → *cancel* → `cancelled`. - **Leaves the run:** `held` → *expire* → `expired`. - **Leaves the run:** `in_service` → *no-show* → `no_show`. - **Rejoins:** `confirmed` → *complete* → `completed`. **Permitted without moving:** `confirmed` admits *join*, *move*, *open*; `held` admits *join*, *move*, *open*. **Terminal:** `cancelled`, `completed`, `expired`, `no_show`. An operation the machine has never heard of is not forbidden — it is not governed. Absence means "no state gates this", never "denied". Drawn from the engine’s declared lifecycle in model.json — the same artifact lint:model --check gates, so this picture cannot drift from the code. **Declared**, at the foot of `engines/booking/src/index.ts`, and emitted to `engines/booking/model.json` — see [Lifecycles](https://substrat.net/concepts/lifecycle.md). The prose below is written from that declaration, and `pnpm lint:model --check` is what stops this page drifting from it. That is the happy path; the terminal branches are wider than it, and the exact legal source states matter: - **cancel** → `cancelled`, from `held` **or** `confirmed` - **complete** → `completed`, from `confirmed` **or** `in_service` (you can complete a booking that was never explicitly started) - **no-show** → `no_show`, from `confirmed` **or** `in_service` Three operations are declared `allow` rather than as edges — **`join`, `open` and `move` are legal in `held` or `confirmed` and move nothing**. A join that fills the last place does end with a confirmed reservation, but the move belongs to `booking/confirm`, the in-scope function join composes; declaring `join → confirmed` would claim every join confirms. States that consume capacity: `held` (unexpired), `confirmed`, `in_service`. The four terminal states — `expired`, `cancelled`, `completed`, `no_show` — release it, and none of them admits substates. **Lazy expiry is not a declared edge.** `held → expired` is one, performed by `booking/expire`. What is not declared is the *lapse*: a hold past its deadline reads as `expired` through `effectiveState` with no transition having occurred. The guard therefore runs on the stored state, and a lapsed hold is refused by `confirm` with its own `hold_expired` reason — which tells a caller something `invalid_transition` never could. `confirm` re-runs the allocation check rather than trusting the hold, because the hold may have lapsed and the slot been taken since. ## Tables | Table | Holds | |---|---| | `booking_resources` | id, kind (vertical vocabulary), name, `capacity`, active | | `booking_reservations` | resource, `starts_at`/`ends_at`, state, `quantity`, `expires_at`, `fill_target`, note | | `booking_participants` | reservation, `party_ref`, share, `joined_at`, `left_at` — append-only | `party_ref` is a `DataSubjectId`, never a `PrincipalId`: a participant is a **customer**, not a staff principal, and the type says so because the value keys crypto-shredding. ## The invariants 1. **No overallocation.** `SUM(quantity)` of live reservations overlapping any instant never exceeds `capacity`. 2. **Half-open intervals.** `starts_at < :end AND ends_at > :start` — back-to-back bookings do not overlap. 3. **A hold carries a deadline.** `CHECK (state != 'held' OR expires_at IS NOT NULL)`. 4. **Lazy expiry.** A lapsed hold stops counting without being swept. Correct for *allocation* — and a trap for *display*, which is why every reservation also carries `effectiveState` (below). 5. **No skipped transitions** — the state machine is declared, and the guard is derived from it rather than restated per operation. 6. **Participants are never deleted.** ## Instants are canonicalised Every instant is normalised to UTC before it is stored or compared. This is load-bearing, not hygiene: the overlap check compares instants as **strings** in SQL, and the contract permits any offset — so `19:00+02:00` and `17:00Z` are the same moment but sort differently as text. Normalising is the only reason lexicographic comparison equals chronological comparison. ## `state` vs `effectiveState` | Field | Meaning | Read by | |---|---|---| | `state` | as stored — a `held` row says `held` until swept | transition guards | | `effectiveState` | `expired` once a hold's deadline passed, swept or not | every read path and UI | Lazy expiry is right for allocation and wrong for rendering: a calendar drawing `state` shows a held cell counting down past `0:00` forever. Guards use the stored value; anything a person looks at uses the derived one. --- # Bookings › Operations & permissions Source: https://substrat.net/engines/booking/surface.md ## Operations Registered bindings, each one a permission check plus a call into the in-scope function below. | Operation | Permission | Does | |---|---|---| | `booking/create-resource` | `booking:manage-resources` | add a bookable resource | | `booking/set-resource-active` | `booking:manage-resources` | take one out of service | | `booking/list-resources` | `booking:read` | a page of resources, optionally filtered by `kind` | | `booking/hold` | `booking:hold` | tentative hold; throws `SlotUnavailable` | | `booking/confirm` | `booking:confirm` *(per reservation)* | held → confirmed, re-checking capacity | | `booking/expire` | `booking:confirm` | surface a lapsed hold as `expired` | | `booking/join` | `booking:create` *(per reservation)* | add a participant; auto-confirms at `fillTarget` | | `booking/leave` | `booking:cancel` *(per reservation)* | soft-leave | | `booking/cancel` | `booking:cancel` *(per reservation)* | held/confirmed → cancelled | | `booking/move` | `booking:move` *(per reservation)* | reschedule; throws `SlotUnavailable` | | `booking/open` | `booking:confirm` *(per reservation)* | put places on offer on an existing reservation, or close it again | | `booking/start` · `complete` · `no-show` | `booking:complete` | service transitions | | `booking/get` | `booking:read` | one reservation with its participants | | `booking/list` · `availability` | `booking:read` | paged reads — a `Page` / `Page` | Checks marked *(per reservation)* pass an `EntityRef`, so a consumer holding an entity-narrowed grant reaches their own booking and no one else's. ## In-scope functions The composable surface. A vertical calls these **inside its own operation and its own permission check**, in one transaction — this is how you extend the engine without forking it. ```ts createResource(ctx, { kind, name, capacity? }) → Resource setResourceActive(ctx, { resourceId, active }) → Resource listResources(ctx, kind?) → Resource[] holdReservation(ctx, { resourceId, startsAt, endsAt, expiresAt, quantity?, fillTarget? }) → Reservation // throws SlotUnavailable confirmReservation(ctx, { reservationId }) → Reservation expireReservation(ctx, { reservationId }) → Reservation joinReservation(ctx, { reservationId, partyRef, share? }) → { participant, reservation } leaveReservation(ctx, { reservationId, participantId }) → Reservation moveReservation(ctx, { reservationId, resourceId?, startsAt?, endsAt? }) → Reservation // throws SlotUnavailable openReservation(ctx, { reservationId, fillTarget }) → Reservation // null closes it again cancelReservation · startReservation · completeReservation · markNoShow getReservation(ctx, reservationId, now?) → { reservation, participants } listReservations(ctx, { resourceId?, from?, to? }) → Reservation[] availability(ctx, { resourceId, from, to }) → FreeInterval[] effectiveStateOf(state, expiresAt, now) → ReservationState // the paged twins — what the three list operations answer listResourcesPage(ctx, page) → Page // page: PageParams, filters: { kind? } listReservationsPage(ctx, { resourceId?, from?, to?, limit?, cursor? }) → Page // cursor is the reservation id availabilityPage(ctx, { resourceId, from, to, limit?, cursor? }) → Page // cursor is a segment's startsAt ``` **What they return is parsed, not asserted.** Every value crossing back out of this engine goes through the schema the engine publishes in `src/schemas.ts` — `resource`, `reservation`, `participant`, `freeInterval` — the same schema the matching operation points its `output` at. Engine surfaces evolve additively (D-28), but that rule is held by review; the parse is what makes the failure it prevents *loud*. A vertical compiled against 0.3 and running against 0.4, whose row shape moved, used to read a field that had become `null` and render it — now the read throws at the seam. The reads name their columns for the same reason: the SELECT list is derived from the row schema (`columnsOf`), where `SELECT *` would publish whatever the physical table happens to hold. That covers the **computed** answers too, not only stored rows. `availability` merges every live reservation in the window into free intervals and parses the fold before handing it back, so a segment that stopped matching `freeInterval` is a throw rather than a wrong gap drawn on a calendar. Every read here is one row, one reservation's roster, one page, or one resource's calendar window, so the parsed set is bounded by the caller's own window — which is why there is no dev-only shortcut, the skew being guarded against lives in production. `engines/booking/src/seam.ts` is one line, `engineSeam('engine-booking')`; the helpers themselves live in `@substrat-run/contracts`. ### The paged twins Each list operation answers with a `Page` — `{ entries, nextCursor }` — and the function behind it is the `…Page` twin, not the array-returning fold above it. Both stay exported on purpose: `listResources(ctx, kind?)` and `listReservations(ctx, window)` are in-scope folds a vertical calls inside its own transaction, where the bound is the vertical's (a club has eight courts, not eight thousand). The unbounded read that paging was introduced against is the invocable *endpoint*, and that is what the twins back. They page differently, and the difference is the cursor. `listResourcesPage` is kernel-composed: `ctx.page` builds the `WHERE`, the `ORDER BY` and the keyset tie-break from the operation's declared `paged` vocabulary, so it takes the kernel's `PageParams` (`limit`, `sort`, `order`, `cursor`, `filters`, `total`) — and with `total: true` it returns a `CountedPage` carrying the filtered count. `listReservationsPage` owns its own `WHERE` because the window is an overlap test (`starts_at < to AND ends_at > from`), which the kernel's equality-only filter vocabulary cannot express. Its rows come back **ordered by reservation `id`**, the cursor is exclusive (`id > cursor`), and the walk is *not* globally ordered by `startsAt` — `startsAt` is not unique on a court schedule, so a caller rendering a calendar sorts the page it got by `startsAt` itself. `availabilityPage` runs the whole fold and takes the page off the end of it (the segments are derived by merging every live reservation in the window, so there is nothing partial to push into SQL); its segments are disjoint and ordered, which is what makes `startsAt` a sound cursor there. Neither handler-composed twin counts: they page with `pageOf`, so there is no `total` to ask for. ### `move`, not `update` `moveReservation` changes resource and/or interval, re-runs the allocation check **excluding itself** (so nudging a booking that overlaps its own old slot is legal), and keeps the reservation's identity, roster and payments. Passing `startsAt` alone *shifts* it, preserving duration — what dragging a calendar cell means. It is explicitly not cancel-then-rebook, which would lose all three. And there is no generic `updateReservation`: engines model named transitions, participants are an append-only log rather than a patchable field, and `booking.moved` carrying from/to is worth more to a consumer than a permanent diff blob. ### `open` — a reservation can be put on offer after the fact `fillTarget` is engine state: it drives the auto-confirm in `joinReservation`. So a booking cannot be opened to others by a vertical keeping its own counter beside the engine's and hoping the two stay in step — `openReservation` sets it, and `null` closes it again into a private booking. Additive: reservations made without a target are unaffected. A target below the people already on the reservation is refused rather than silently stranding one of them. This is what lets the three shapes of "open game" share one mechanism: a club opening a court with nobody on it, a player opening one they are on, and a player opening a booking they already hold. ### `availability` returns intervals, not slots `FreeInterval[]` — `{ startsAt, endsAt, available }`, merged, with `available` a **number** because capacity may exceed 1. It reports raw gaps between reservations and knows nothing of opening hours: left alone it will happily call 03:00 free. Intersecting with the venue's bookable window is the vertical's job. This shape is deliberate. With mixed durations there is no canonical slot list — "does 90 fit at 19:00? does 120?" is a question about gaps, and a fixed slot list cannot answer it. ## Permissions | Key | Grants | |---|---| | `booking:create` | add participants to a reservation | | `booking:read` | resources, reservations, availability | | `booking:hold` | place a tentative hold | | `booking:confirm` | confirm a held reservation | | `booking:cancel` | cancel a reservation, or leave one | | `booking:move` | reschedule to another slot or resource | | `booking:complete` | start service, complete, or mark a no-show | | `booking:manage-resources` | create, edit and deactivate resources | `booking:move` is separate from `booking:cancel` on purpose: staff who may reschedule a customer are not necessarily staff who may cancel and refund one. `booking/open` is guarded by `booking:confirm` rather than a key of its own: whoever may confirm a reservation may decide whether it is on offer. ## Entitlement `entitlementKey: 'booking'`. Default-deny — grant it to a tenant before its operations resolve. --- # Bookings › Events Source: https://substrat.net/engines/booking/events.md ## Emitted | Event | When | |---|---| | `booking.resource-created` | a bookable resource is added | | `booking.held` | a tentative hold is placed | | `booking.confirmed` | a hold becomes firm | | `booking.expired` | a lapsed hold is surfaced | | `booking.cancelled` | held or confirmed → cancelled | | `booking.moved` | rescheduled — carries `from` and `to` | | `booking.started` | service begins | | `booking.completed` | the terminal success transition | | `booking.no-show` | the party did not turn up | | `booking.participant-joined` | someone joined | | `booking.participant-left` | someone left | | `booking.opened` | places were put on offer on an existing reservation, or withdrawn | Consumes nothing. ## The PII split that shapes every payload Three platform rules collide here, and the resolution is worth understanding before you write a consumer. Fat events are required. A match may have four participants. And the event envelope permits **exactly one** `subjectId`, mandatory whenever `piiClass !== 'none'`, because crypto-shredding must be able to key the erasure. A four-person roster cannot be keyed to one subject, and the envelope has no plural. So the roster does not ride the aggregate event: | Event | `piiClass` | Carries | |---|---|---| | `participant-joined` / `-left` | `pseudonymous`, `subjectId` = that party | the one `partyRef` + share | | every reservation lifecycle event | `none` | resource, interval, quantity, **`participantCount`** | This is better privacy design than the alternative, not a workaround: - **The club's business record survives an erasure.** "Court 1 was booked 17:00–18:30 and completed" names nobody, so it is retained as a legitimate business record while the personal link is shredded with the per-participant events. - **Each pseudonymous fact is individually shreddable**, keyed to exactly the subject it concerns — which is what the envelope was asking for. - **`partyRef` is a `DataSubjectId`**, not a free string. Participants are people; the type says so. ### Reconstructing a roster Correlate `participant-joined` / `-left` with the lifecycle event on `reservationId`. That is ordinary stream processing over one module's own events — **not** a cross-module read, so the fat-event rule is intact. ### Split billing does not need this A vertical composes invoicing's in-scope functions in the **same transaction**, where it already holds the roster. It never needed the event. ## No `booking.match-played` `booking.completed` is the same fact. One transition should not emit two events; a vertical wanting match vocabulary emits its own. ## Evolution rules - Payload fields are **frozen once shipped**. Rename, remove or retype means a `schemaVersion` bump — a **replace, not a dual-emit**. Consumer dispatch keys on the event type alone, so two versions in flight deliver *both* to every consumer of that type; read [the invoicing engine's `underlag-exported` v2 note](https://substrat.net/engines/invoicing/events.md#versioning) before bumping anything here. - New operation inputs are optional with behaviour-preserving defaults. - Permission keys are never renamed. - Consumers parse payloads with their own Zod schema, never the producer's types. --- # Bookings › Composing & extending Source: https://substrat.net/engines/booking/composing.md ## Using it as-is Register it and the default bindings work: ```ts import { bookingModule } from '@substrat-run/engine-booking'; host.registerModule(bookingModule); ``` That gets you resources, holds, confirms and availability under the engine's own permission keys. Most verticals wrap `hold` and `confirm` instead, because that is where their own rules live. ## The timezone boundary **The engine is timezone-free.** It stores and compares absolute instants and never does calendar arithmetic. All local wall-clock reasoning belongs to the vertical: - the venue's **IANA zone** (never a fixed offset — offsets move with DST); - recurrence defined in local wall time and materialised to instants, so a weekly 19:00 slot stays at 19:00 across a DST boundary; - **absolute** durations — 90 minutes is 90 real minutes even across a transition; - **nonexistent** local times (the spring-forward gap) rejected at input validation, and **ambiguous** ones (the autumn repeat) resolved to the earlier instant by convention. The split is not fussiness. "Do these two bookings overlap?" is a question about physical time; "19:00 on Tuesdays" is a question about human intent. Keeping them apart makes the invariant trivially correct and the engine reusable in any locale. ## Wrapping it with vertical logic The pattern: validate your own rules, resolve your own price, then hand the engine an absolute interval and let it arbitrate. ```ts const bookCourt: OperationHandler = async (ctx, raw) => { assertAllowed(await ctx.check(BK.hold)); const input = bookInput.parse(raw); // Vertical: opening hours, allowed durations, price. const window = bookableWindow(ctx, input.resourceId, input.date); if (!window) throw new Error(`the club is closed on ${input.date}`); const startsAt = zonedToInstant(input.date, input.time, venue(ctx).timezone); const { price, label } = resolvePrice(ctx, input); // Engine: the only thing that decides whether the slot is free. const reservation = holdReservation(ctx, { resourceId: input.resourceId, startsAt, endsAt: addMinutes(startsAt, input.duration), expiresAt: addMinutes(now, venue(ctx).hold_minutes), }); // Your own side table, keyed by the engine's id. ctx.sql.exec(`INSERT INTO rally_bookings (reservation_id, price_amount, …) VALUES (?, ?, …)`, [reservation.id, price.amount /* … */]); return { reservation, price, ruleLabel: label }; }; ``` Note what is absent: **no clash check**. If the court is taken the engine throws `SlotUnavailable`. A vertical that checks first is doing it wrong — it cannot do so correctly, and the engine already does. ## Configuration ### Adding data to a reservation Add your **own side table keyed by the engine's id** — never a column on the engine's tables, which are private to it. Price, rule label, level band, whatever the domain needs. ### Holds are your policy `expiresAt` is yours: ten minutes for a payment hold, the match start time for an open match awaiting players. One mechanism, two products. ### Open matches ride the same mechanism `fillTarget` plus a deadline *is* the open-match feature. Participants join; the engine auto-confirms on reaching the target and expires the hold if the deadline passes unfilled. The level bands, approval rules and cancellation windows around it are all vertical — the engine knows only the number and the deadline. ## Composing with another engine Star topology: `booking` never imports another engine, and none imports it. A vertical composes both in one transaction — a workshop's drop-off is a `booking` while the repair is a `workorder`; a club's confirmed booking raises an invoice through `invoicing`. Because both run inside one scope, which is one Durable Object, the composition is atomic for free. Debiting a prepaid balance and confirming a court either both happen or neither does — "charged but not booked" is unrepresentable rather than merely unlikely. ## Portal reads For "show me my bookings", declare an entity relation from `reservation` to whatever your vertical calls a customer, link it when you create the reservation, and grant that customer `booking:read` narrowed to their own record. Then iterate and `ctx.check(perm, entityRef)` per reservation — a proof walk, not a `WHERE` clause on the caller. ## Reaching the outside world The engine emits; it never calls out. Payment rails, notifications and calendar sync are connectors draining the outbox. Module code has no network, and a booking engine that could call a payment provider would be a booking engine you could not trust to be atomic. --- # Invoicing › Overview Source: https://substrat.net/engines/invoicing/index.md `@substrat-run/engine-invoicing` — accumulates **invoice bases** (Swedish: *invoice basis*) from billable events and makes them **immutable once exported**. It is the reference example of star-topology composition *and* cross-domain reuse: it builds invoice bases from events in more than one domain — work-order completions and retail orders — with zero imports from either producer. ## At a glance | | | |---|---| | **Package** | `@substrat-run/engine-invoicing` | | **Entitlement key** | `invoicing` | | **Owns** | invoice-basis accumulation, one-document-one-currency, immutability on export | | **Emits** | `invoicing.underlag-updated` (v1), `invoicing.underlag-exported` (**v2**) | | **Consumes** | `workorder.completed`, `commerce.order-placed` — with zero imports from either | | **Permissions** | 2 (`invoicing:read` · `invoicing:export`) | | **Composed** | **by event** — you emit, it consumes, you read back; [no in-scope functions, by design](./surface#in-scope-functions) | | **Status** | product seed (0.x) — surfaces change until the first vertical ships | ## What it owns - **Exported means immutable.** Once a basis is exported, nothing appends, nothing edits, and exporting again throws. - **One document, one currency.** A delivery whose lines disagree on currency is rejected at write time rather than producing a total that means nothing. - **Snapshot, not join.** Prices and quantities are frozen from the event payload; provenance is kept as `EntityRef` columns. - **Idempotent on replay.** The source order is the dedup key, so a redelivered completion adds nothing rather than billing twice. Details: [Domain model & invariants](./model). ## What it will not do - **Issue invoices.** It produces the *basis* — billable lines with frozen prices and provenance, ready to hand to whatever actually invoices. - **Bookkeeping.** Not a ledger, not accounts receivable, not payment tracking. That territory belongs to accounting systems, reached through connectors. - **Pricing.** Prices arrive priced, in the event payload. The engine sums; it never rates. ## Is this a good match? | Reach for it when | Look elsewhere when | |---|---| | Billable facts arrive as **events** from elsewhere | You want to build an invoice by hand in a form — nothing consumes to | | You need a frozen, auditable basis handed to an accounting system | You need the invoice itself, with numbering and VAT logic | | "What was this charge based on, and where did it come from?" must be answerable years later | Nobody will ever ask | | You want prices frozen at the moment of the fact, not at read time | Re-pricing history is acceptable | | Your bookkeeping lives in Fortnox/Visma-class software | You intend to *be* the ledger | The clarifying question: **do you need the artifact that justifies an invoice, or the invoice?** This engine owns the first and deliberately stops at the second. Cross-domain reuse is proven here, not theoretical: the same immutability and snapshot machinery serves a field-service firm (`workorder.completed`) and a coffee roaster (`commerce.order-placed`), and adding the second was a purely additive change. --- # Invoicing › Domain model & invariants Source: https://substrat.net/engines/invoicing/model.md ## Tables Created by migration `0001-init`, inside each scope's own database: - **`invoicing_underlag`** — id, sequential `number`, customer as `EntityRef` columns (`customer_type`/`customer_id`), `status` (`open`/`exported`), `created_at`, `exported_at`. - **`invoicing_lines`** — underlag, provenance (`source_type`, `source_id`), article, description, qty, unit, unit price (amount + currency), line total, `created_at`. There is no customer table and no article table — those are opaque refs the vertical owns. ## The immutability invariant An underlag is `open` or `exported`: - While **open**, consumed events append lines. - **`invoicing/export`** flips it to `exported` — and from then on it is immutable. Nothing appends to it, nothing edits it, and attempting to export it again throws. - Billable work arriving *after* export (a late time report, a re-opened order) opens a **new** underlag. History is never rewritten; late facts become new facts. This is the engine invariant on top of the kernel's guarantees: the kernel makes events trustworthy; the engine makes the financial artifact derived from them tamper-proof. ## Snapshot, not join Each consumed event has its **own** consumer with its **own** Zod view of the payload — never the producer's types. When a work order completes, the consumer: 1. **Parses its own view of the payload**: ```ts const completedPayload = z.object({ orderId: z.string().min(1), number: z.number().int(), customer: entityRef, billable: z.array(z.object({ article, description, qty, unit, unitPrice: money, lineTotal: money, sourceType, sourceId })), total: money, }); ``` 2. **Guards against replay** — if lines for that `source_id` already exist, return. 3. **Finds or creates the open underlag** for that customer — one open collection per customer at a time. 4. **Copies the billable lines** with provenance (`source_type: 'workorder'`, `source_id: orderId`). 5. **Emits `invoicing.underlag-updated`.** Prices and quantities are **frozen from the event payload** at the moment the work order completed. If the vertical's price list changes tomorrow, yesterday's underlag doesn't — which is exactly what an invoice basis must guarantee. Provenance is kept as `EntityRef` columns, so every line can answer "where did you come from?" without the engine ever querying the work-order tables. The `commerce.order-placed` consumer is the same five steps with `source_type: 'order'` (a discount arrives as a negative line, so the total stays net), plus one domain rule: **only invoice-payment orders bill** — card and Swish settle through a payment connector, so those events return early. ## Idempotency Consumers are delivered **at-least-once** and run under a system actor. Three things keep the handlers idempotent: 1. find-or-create on the underlag, 2. the kernel's delivery journal, 3. a **source-id guard** — before inserting anything, each consumer asks whether lines for that `source_id` already exist and returns if they do. The source order is the dedup key, so a replayed completion adds nothing rather than billing the customer twice. Dispatch is **post-commit**, and each consumer runs in **its own transaction**. A consumer that throws does not roll back the producer — it rolls back only its own work, and the delivery is **dead-lettered** (journalled with the error) so one poison event can't wedge the loop. That's why a malformed payload leaves no half-built underlag, and why `await` on the producing operation tells you nothing about whether the consumer succeeded. ## One document, one currency Totals use the sanctioned [exact decimal arithmetic](https://substrat.net/concepts/money.md) — and `addMoney`, not `addDecimal`, precisely because it is **currency-aware**. Summing 100 SEK and 100 EUR into `200` is not a rounding error; it is a financial artifact stating a number that means nothing, so the engine refuses instead. A delivery whose lines disagree with each other, or with the lines already on the open underlag, is rejected **at write time** and dead-lettered — so a mixed-currency event never creates a document that can't be read or exported. Rejecting at read time instead would leave a document that can never be listed or exported again. > **Warning — Currency lives on the line, not the document** > > Currency is carried per line today, so an underlag with no lines has no currency to report — > attributing one to an empty document is exactly the guess this engine shouldn't make. The > optional `currency` on `invoicing/export` is where a caller says instead > ([#967](https://github.com/substrat-run/substrat/issues/967)); omit it and the fallback is > still `SEK`, so every existing caller keeps the total it has today. When there ARE lines they > decide, and a `currency` that contradicts them is refused with `currency_mismatch` rather than > quietly ignored. Hoisting currency onto the underlag itself is still the honest fix; it needs > a migration and therefore a human review checkpoint. --- # Invoicing › Operations & permissions Source: https://substrat.net/engines/invoicing/surface.md An engine has **no endpoints**. It exposes operations (invoked through a scope stub) and in-scope functions (called by a vertical inside its own transaction). HTTP, where it exists, is a generated artifact pointed at by the manifest's `api` field. Most of this engine's surface, though, isn't either one — it's the **consumers**. Lines arrive by event, not by call. See [Events](./events). ## Operations | Operation | Permission | Does | |---|---|---| | `invoicing/list` | `invoicing:read` | list underlag (optionally by status), each with its computed total | | `invoicing/get` | `invoicing:read` | one underlag with all lines and total | | `invoicing/export` | `invoicing:export` | flip to `exported` — the point of no return; optional `currency` labels the total of a basis with no lines (default `SEK`) | There is no `create` and no `add-line`: an underlag is never authored, only accumulated. The only way to put a line on one is to emit an event this engine consumes. That is the design — a basis nobody can hand-write is a basis nobody can forge. ## In-scope functions **This engine exports none, deliberately.** All three operations carry their logic inline. > **Tip — Composed by event, not by call — the absence is the design** > > An engine is composed one of two ways, and that decides its shape. A **by-call** engine — > work orders, bookings, protocols — keeps its logic in exported in-scope functions, and its > operations are thin, so a vertical wraps the functions inside its own transaction. Invoicing > is the **by-event** one: a vertical does not build an invoice basis, it emits a billable fact > and this engine's consumers build the basis from that event's payload. Here the operations > *are* the surface, and their logic living in them is correct rather than an omission. > > The missing exports are load-bearing. This engine is the **only writer of its rows**, which > is what keeps `exported` genuinely immutable: a caller cannot export an underlag half-way > through a delivery that is still appending lines to it. Extracting `exportUnderlag(ctx, …)` > for a vertical to call would hand out exactly that race, so it is not a purely additive > change — it is a change of who owns the invariant. Compare the > [work-order engine](https://substrat.net/engines/workorder/surface.md#in-scope-functions), which exports > `createWorkOrder`, `completeWorkOrder`, and friends precisely because a vertical is meant to > wrap them. So a vertical reaches the result by reading it back, not by calling in: through `invoicing/list` / `invoicing/get`. `invoicing.underlag-updated` is the *notification* that there is something new to read — it carries `{ underlagId, addedLines, source }`, not the whole basis — so a vertical projects it into its own side table keyed by the underlag's id (decision 28) and calls `invoicing/get` when it needs the basis and its computed total. The cost is real and worth stating plainly: a vertical **cannot** export an underlag and touch its own tables in one transaction, and cannot wrap export in its own vocabulary. That is what the immutability guarantee is bought with. What *is* exported: `INVOICING_PERM`, `invoicingManifest`, `invoicingMigrations`, the `UnderlagRow` / `UnderlagLine` row types, and `invoicingModule`. ## Permissions | Key | Description | |---|---| | `invoicing:read` | Read invoice basis | | `invoicing:export` | Export an invoice basis (makes it immutable) | Two keys, and the split is the point: reading a basis is routine, and **export is irreversible**. Keep `invoicing:export` on a back-office role, not on whoever can see totals. ## Entitlement `entitlementKey: 'invoicing'`. This engine is priceable independently of any other — a tenant can hold `workorder` without `invoicing`. The gate is checked per invoke and fails closed. > **Warning — The entitlement gate is on operations, not consumers** > > `dispatch` iterates every registered module's consumers with **no entitlement check** — only > `invoke` consults `operationEntitlement`. So if the invoicing module is registered on the > host, an unentitled tenant's `workorder.completed` events still build underlag rows in their > scope; they simply can't `list`, `get`, or `export` them. > > "Not entitled" therefore means *invisible*, not *inert* — the accumulation happens either way. > Grant the entitlement later and the history is already there, which is convenient, but it is > not what "a module loads for a tenant only if the tenant holds its SKU flag" implies. Worth > knowing before you price this engine as off. --- # Invoicing › Events Source: https://substrat.net/engines/invoicing/events.md This engine is mostly a **sink**. Lines arrive by event, never by call — which is why the consumed side is the more interesting half. ```ts events: { emits: [ { type: 'invoicing.underlag-updated', schemaVersion: 1 }, { type: 'invoicing.underlag-exported', schemaVersion: 2 }, ], consumes: [ { type: 'workorder.completed', schemaVersion: 1 }, // the original producer { type: 'commerce.order-placed', schemaVersion: 1 }, // added for the shop vertical ], } ``` ## Consumed | Event | From | Handler behaviour | |---|---|---| | `workorder.completed` | the work-order engine | append lines, `source_type: 'workorder'` | | `commerce.order-placed` | the shop vertical | append lines, `source_type: 'order'` — **only if `paymentMethod === 'invoice'`** | Each has its **own** consumer with its **own** Zod view of the payload. Zero imports from either producer — the engine has never heard of the packages that emit these. Adding the second was a **purely additive change**: a new `consumes` entry, a new parser, a new handler. No migration, no permission, the `workorder.completed` path untouched. That's engine reuse across verticals, demonstrated rather than asserted. The mechanics — replay guard, find-or-create, dead-lettering — are in [Domain model & invariants](./model#snapshot-not-join). ## Emitted | Event | v | piiClass | Payload | |---|---|---|---| | `invoicing.underlag-updated` | 1 | none | `{ underlagId, addedLines, source: EntityRef }` | | `invoicing.underlag-exported` | **2** | none | `{ underlagId, number, total: Money }` | `invoicing.underlag-exported` is the natural hook for the next step in a vertical: an accounting connector that turns the frozen basis into a real invoice. See [Composing](./composing#reaching-the-outside-world). ## Versioning > **Warning — `underlag-exported` is at schemaVersion 2 — a replace, not a dual-emit** > > v1 carried `total` as a bare amount string with no currency — an amount that isn't an amount, > on the one event an accounting connector consumes. v2 makes it `Money`. If you consume this > event, read `total.amount` and `total.currency` rather than the old string. > > This is not an exception — it is the rule, and the reason generalises to **every** engine > here: > > **Consumer dispatch keys on event `type` alone.** The `schemaVersion` in a manifest's > `consumes` is discarded at registration; the dispatch query is `WHERE o.type = ?`. So > emitting v1 and v2 together would deliver *both* to every consumer of the type — and for an > export event, that means a connector could invoice the same basis twice, silently. > > A clean replace fails **loudly** instead: a v1 consumer's strict parse rejects v2 and > dead-letters, which is visible. Loud beats silent when the alternative is double-billing. The rest of the rule holds unchanged everywhere: payload fields are frozen once shipped, new fields are optional and additive, and rename/remove/retype means a version bump. Only the deprecation window is gone — a bump is a replace on every engine, not just this one, until dispatch routes on `(type, schemaVersion)`. That is wanted and postponed, not abandoned: [#128](https://github.com/substrat-run/substrat/issues/128) holds the scope. --- # Invoicing › Composing & extending Source: https://substrat.net/engines/invoicing/composing.md ## Using it as-is ```ts host.registerModule(invoicingModule); ``` Then **emit the events it consumes** — that's the whole integration. There is no wiring step between the work-order engine and this one, because there is no connection between them: one emits, the kernel journals, this one consumes. To make a vertical's own domain billable, emit a `commerce.order-placed`-shaped event from your operation. You never import this engine to do it. ## Extending it **By event, not by call** — and that is the shape, not a shortfall. There are **no in-scope functions** ([surface](./surface#in-scope-functions)), because this engine being the only writer of its rows is what makes `exported` immutable. So the wrap-the-function pattern the by-call engines offer does not apply here: you cannot wrap `export` in your own vocabulary, and you cannot export a basis and write to your own tables in one transaction. You extend invoicing by changing **what you emit**, and you read its answers back through `invoicing/list` / `invoicing/get` — with `invoicing.underlag-updated` as the notification that there is something new to read. What that looks like: | You want | Use | |---|---| | billing from a new domain | emit an event this engine already consumes — the `commerce.order-placed` path is the worked example. A genuinely new event *type* means adding its consumer to `invoicingModule`, in this engine; there is nothing to wire on your side | | the basis on your own screens | `invoicing/list` / `invoicing/get` — never a read of this engine's tables | | to know the moment it changes | consume `invoicing.underlag-updated`. It is a change notification (`{ underlagId, addedLines, source }`), not a snapshot: project it into a side table keyed by the underlag id, and read `invoicing/get` when you need the whole basis and its total | | extra fields on an underlag | your own side table keyed by the underlag id — **never** a column upstream | | to hide export from most roles | keep `invoicing:export` off the role; it's already a separate key | | the engine off for a tenant | revoke the `invoicing` entitlement — but read the caveat in [surface](./surface#entitlement) first | ## Configuration **There is none.** `ModuleRegistration` has five fields and none is config; there is no `createInvoicingModule({...})` factory and no `config` field on the manifest. An engine cannot be told anything at registration time. *Configuration is dynamic; composition is code.* The one behaviour you might reasonably want to configure — *"only invoice-payment orders bill"* — is **hard-coded** in the `commerce.order-placed` consumer: ```ts if (p.paymentMethod !== 'invoice') return; ``` A vertical that settles differently can't change that rule; it would emit a different event or carry a different `paymentMethod`. Whether that belongs in the engine at all is a fair question — it is a business decision living in shared machinery, which is exactly what the engine/vertical line is supposed to keep out. ## Reaching the outside world Nothing in this engine talks to anything external, and it never will: module code may not `fetch`, and connectors handle the outside world. **`invoicing.underlag-exported` is the seam.** Its consumer is by design an accounting connector (Fortnox/Visma-class) that turns the frozen basis into a real invoice. That the event exists, carries `Money`, and fires exactly once per export is the whole contract. The immutability invariant exists *for* this seam: a connector must be able to trust that what it read can't change underneath it, and that exporting twice is impossible. ### So how would Stripe attach? Not to this engine, and the answer depends on who's paying whom: | Case | Bucket | Where it lives | |---|---|---| | A tenant's customer pays a card/Swish charge | **connector** | the integrations hub — a capability tenants use | | Substrat charges its own tenants | **adapter** | infrastructure the kernel consumes, swappable behind a pure interface | Fortnox lands in *both* buckets depending on the same question: a connector when it's a tenant's bookkeeping, an adapter when it's the platform's own invoicing rail. A connector is a **fourth bucket** — not kernel, not engine, not vertical. It lives in the integrations hub, and the hub itself is kernel-owned while the connectors in it are not. The test that decides it: *effects on the outside world are connectors.* > **Info — The seam is built; an accounting connector is not** > > The [connector seam](https://substrat.net/connectors/index.md) is real and running in production — `registerConnector` > binds a handler to an event type, the per-tenant connection store holds the credential sealed > at rest, delivery is at-least-once with retry and a dead letter, and a provider's write-back > comes home through the inbound authority seam. The [Scrive connector](https://substrat.net/connectors/scrive.md) uses > all four. > > What is missing is an **accounting** connector. Nothing consumes > `invoicing.underlag-exported` today — no Fortnox, no Visma, no Stripe — so the event fires, > carries its `Money`, and reaches no bookkeeping system until somebody writes the consumer. The > engine is pre-shaped for a connector it does not yet have, which is deliberate sequencing; it > is not an invitation to read `underlag-exported` as something already wired to Stripe. Note also what *doesn't* belong behind that seam: reskontra and avisering (ledger, dunning) stay out of the engine entirely. The basis goes out; the bookkeeping stays in the bookkeeping system. --- # Protocols › Overview Source: https://substrat.net/engines/protocol/index.md `@substrat-run/engine-protocol` — **protocols and checklists with the sign → immutable invariant**: self-inspections, installation protocols, service checklists, per-item condition reports. A protocol is the compliance artifact a field-service or workshop business must be able to produce, unaltered, years later. The engine owns only the invariants. Template **content** — which protocols exist, what sections and items they contain, which are mandatory when — is 100% vertical-owned. An instance binds to any `EntityRef` — a work order, a bike's condition report, or an employee's **onboarding checklist**, all three shipping in the demo verticals — and the engine never knows the vertical's vocabulary. (The HR vertical even signs onboarding as the *employee*, not a supervisor: same engine, the vertical's grant decides who signs.) Both content kinds ship in [Meridian](https://substrat.net/verticals/meridian.md#engines-composed): onboarding as a `checklist`, and the **anställningsavtal** as a `document` signed by an employer principal and a new hire who has no account yet. It is the worked example for everything below. ## At a glance | | | |---|---| | **Package** | `@substrat-run/engine-protocol` | | **Entitlement key** | `protocol` | | **Owns** | version-pinned templates, append-only responses, freeze→immutable, a verifiable content hash, signature requests | | **Emits** | 9 events, `protocol.instantiated` → `protocol.voided` ([events](./events)) | | **Consumes** | nothing | | **Permissions** | 9 (`protocol:create` · `fill` · `bind` · `request-signature` · `record-signature` · `sign` · `countersign` · `read` · `void`) | | **Contributes** | the `protocol/all-signed` guard predicate — the only config-shaped surface in any engine | | **Status** | product seed (0.x) — the extraction proof | ## What it owns 1. **Freeze freezes.** Content is writable only while an instance is `open` — whether it froze at an in-app signature or at dispatch to an external signing provider. 2. **Verifiable content hash.** SHA-256 over template content plus the frozen content — replayable against stored rows by anyone. One recipe per content kind. 3. **Counter-signature on frozen content.** A second signature whose hash must equal the primary's, so it can never silently attach to changed content. 4. **Append-only responses.** An edit is a new row; the history *is* audit material. 5. **Version-pinned templates.** An instance pins `(key, version)` at instantiation forever. 6. **Void, not delete.** A superseded protocol is voided with a reason, never removed. 7. **A signature is over a *frozen* hash, by a named signatory, at a stated time.** The signatory may be a principal or an **external person with no account**, which is what makes a BankID/Scrive flow expressible without pretending it is a synchronous in-app tap. Details: [Domain model & invariants](./model). ## What it will not do - **Template content** — sections, items, vocabulary, branschprotokoll packs are the vertical's. The engine validates fills against the pinned template shape; it authors no templates. - **Talk to external signature providers** — the engine emits a request and a connector dispatches it; it imports no vendor SDK and makes no network call. The *inbound* half (ingress, and an authority seam letting a non-principal callback invoke an operation) is built and lives outside this engine too — see [the connector seam](https://substrat.net/connectors/index.md#the-seam-a-connector-plugs-into). - **Render or reconcile a document** — a `document` instance holds a ref and a hash, never bytes, and recomputing that hash from your own rows is your obligation, not the engine's. - **Branching/conditional templates, scheduled instantiation, PDF rendering, a photo pipeline, offline sync, a template marketplace** — explicit v0 non-goals. ## Is this a good match? | Reach for it when | Look elsewhere when | |---|---| | Someone must **attest** to a set of answers, and that attestation must hold up later | You just need a form; a table and a timestamp are cheaper | | "Prove this wasn't altered after signing" is a real question | Nobody will audit it | | The person who fills is not always the person who signs | One actor does everything | | A second party (customer at pickup) confirms the same content | There's no counterparty | | Parties sign **asynchronously**, via a provider, possibly without accounts | Everyone signs in-app, in session | | The signed thing is a **document you own** (an avtal), not a checklist | It is neither | | The checklist's *content* is yours and changes over time | You want the engine to ship the checklists — it won't | The clarifying question: **is the signature load-bearing?** If the value is in the answers, you want a form. If the value is in *who stood behind the answers, and that they haven't changed since* — that's this engine. This engine is the **extraction proof**: it was forced out of vertical code only when a *second* vertical (a bike shop's per-bike condition report) needed the same sign-immutability invariant in a different shape. Engines get extracted when a second consumer proves the line, not when someone predicts one. --- # Protocols › Domain model & invariants Source: https://substrat.net/engines/protocol/model.md ## Templates, instances, responses, signatures Five concepts, mirrored by five tables (migrations `0001-init` and `0002-signature-requests`, inside each scope's own database): - **`protocol_templates`** — `key`, `version`, `title`, and `content_json`. Immutable per `(key, version)`: new content is a **new version**, never an edit. Content comes in two **kinds** (below): a `checklist` of sections → items, or an opaque `document`. - **`protocol_instances`** — a template pinned at `(key, version)` bound to an `entity_type`/`entity_id`, with `status` `open` → `pending_signature` → `signed`, or `voided`. Carries the `frozen_hash` once content freezes, plus the `content_ref`/`bound_hash` a document instance binds. - **`protocol_signature_requests`** — one row per party a document was sent to for signature: `party_label`, `party_kind`, `method`, `status`, `external_ref`. The noun that makes asynchronous, multi-party signing expressible. - **`protocol_responses`** — append-only fill entries: `item_key`, `value_json`, `note`, `responded_by`, `responded_at`. **Latest-per-item wins**, ordered by append (`rowid`, same-millisecond safe) — the history *is* audit material ("value changed from 4.2 to 5.1 before signing"). - **`protocol_signatures`** — `signed_by` (the signatory's reference), `signatory_kind` (`principal`/`external`), `kind` (`primary`/`counter`), `method`, `content_hash`, optional `evidence_ref` and `request_id`. Exactly one `primary` per signed instance. The template pins at instantiation **forever**: editing a template tomorrow never rewrites what a signed document referred to. ## The invariants 1. **Freeze freezes.** Content is writable only while an instance is `open`. Freezing happens either at in-app signature or at `requestSignatures`, and from that moment fills and rebinds fail at the engine. 2. **Verifiable content hash.** At freeze time the engine computes a SHA-256 and stores it as the instance's `frozen_hash`. Every signature must match it. The recipe is the contract — anyone can replay it against the stored rows and compare. One recipe per content kind: ``` checklist: '@\n\n' + 'item=value_json\n' per item, sorted by key document: '@\n\ndocument:\n' ``` The checklist recipe is byte-identical to the one that shipped originally, so signatures made before the document kind existed still verify. The hash is Web Crypto (`globalThis.crypto`), so the same code runs in Node, Workers, and the browser. 3. **Counter-signature on frozen content.** A counter-signature (the customer at pickup) is a *second signature row* on the **same** frozen content — the engine recomputes the hash and it must equal the primary's, so a counter-sign can never silently attach to changed content. A principal never counter-signs what they primary-signed, and never counter-signs twice. 4. **Append-only responses**, bound to an open instance. 5. **Every mutation emits a fat event** ([events](./events)) — a consumer never needs a cross-module read. 6. **Void, not delete.** A superseded protocol is `voided` (with a reason and an event), never mutated or removed; a replacement is a new instance. ## Two content kinds The attestation half of this engine — the hash, the freeze, the signatures, the guards — was always content-agnostic. Only `fill` and the template shape were checklist-specific. That seam is exposed as a discriminated `kind`: - **`checklist`** — sections → items, filled response-by-response. Item types are `check` (boolean), `value` (a measurement — decimal string, with an optional `unit` like `MΩ`), and `text`. Templates written before the discriminant existed carry no `kind` and parse as `checklist`; their stored `content_json` is never rewritten, because the hash covers that string verbatim. - **`document`** — content the engine **never sees**. A priced avtal, a styrelserapport, a PDF: the vertical owns the rows, computes their hash, and binds `(contentRef, contentHash)` to the instance with `bindDocument`. Rebinding is allowed while open — an avtal's price moves during negotiation — and each rebind moves what a signature will attest to. > **Tip — Why not model a contract as a one-item checklist?** > > Because the engine would then attest to the sentence "I accept this contract" and nothing > else. The hash covers template content plus responses; if the real content lives in your > vertical, a degenerate checklist produces a signature that looks like evidence and is not. > The `document` kind says out loud what is actually true: the signature is over a hash the > vertical computed. `documentContent.hashRecipe` is a **required** field for exactly that > reason — a signature over an unreproducible hash is worth nothing, so the method for > reproducing it has to be written where an auditor will find it. > > What the engine proves for a document: this signature was made over exactly this hash, at > this time, by this signatory, and the hash has not changed since. What it cannot prove: that > your rows still hash to it. That check is yours — see Meridian's `hr/verify-contract` for > what owning it looks like. ## The signature model: provider-agnostic evidence A signature records its signatory + `method` + `content_hash` + optional `evidence_ref`. The **`in-app`** method is the everyday field case: the authenticated principal taps sign, and integrity comes from the engine itself — the hash, the immutability, the spine event. No third party, works self-hosted. Freezing and signing coincide, which is sound precisely *because* it is synchronous: there is no window between what the signer saw and what was hashed. **External providers (BankID via Scrive, …) are a different operation shape, not the same one with better evidence.** Three things that `signProtocol` takes from ambient context are not available: | | in-app | external provider | |---|---|---| | who signs | `ctx.principal` | a person with **no account** | | when | now | days later, at the provider's timestamp | | freeze | at signature | at **dispatch** — or the document drifts while it is out | So the external path is `requestSignatures` → *(days)* → `recordSignature`, and the signatory is data rather than context: ```ts signatory: | { kind: 'principal'; ref: PrincipalId } | { kind: 'external'; ref: DataSubjectId } // opaque, shreddable ``` > **Danger — Never put a personnummer in `ref`** > > It is `direct` PII, and `subjectId` on the emitted event is what crypto-shredding keys the > erasure on. A `DataSubjectId` is shreddable; a personnummer written into a signature row is a > GDPR liability that immutability makes permanent. The provider's own party identifier belongs > in `evidence_ref`. This follows `engines/booking`'s `partyRef` for the same reason. eIDAS advanced/qualified levels *are* an evidence-quality upgrade — but only once the flow that produces them exists. The engine never imports a vendor SDK. (See [Composing](./composing#reaching-the-outside-world) for what is and is not built.) ## The lifecycle **Diagram — the protocol state machine**, on `status`, starting at `open`. - **The main run:** `open` → *request-signatures* → `pending_signature` → *complete-signing* → `signed` → *void* → `voided` - **Rejoins:** `open` → *sign* → `signed`. - **Rejoins:** `open` → *void* → `voided`. - **Rejoins:** `pending_signature` → *cancel-signature-requests* → `open`. - **Rejoins:** `pending_signature` → *void* → `voided`. **Terminal:** `voided`. An operation the machine has never heard of is not forbidden — it is not governed. Absence means "no state gates this", never "denied". This engine declares no lifecycle yet, so the machine above is transcribed from its status enum and guard clauses. Nothing re-checks it — treat it as documentation, not as the contract. `pending_signature` is frozen: no fill, no rebind. It is the state that closes the drift window — with freeze welded to signing, an instance sitting at a provider for days stayed `open` and writable, so the document the customer saw could differ from the one that was hashed, undetected. An instance reaches `signed` only when **every** requested party has signed. A declined or expired request is not "pending", but it is not a signature either — treating it as completion would mark an avtal fully executed that a party refused. An unresolved refusal holds the instance frozen until someone explicitly withdraws the request set, which is a separate, permissioned, audited act. Cancelling **thaws**: status returns to `open` and `frozen_hash` clears, so the next request freezes fresh content at a fresh hash. Signatures already collected are kept — they are append-only history attesting to content that really was frozen — but they were taken over the old hash and can never satisfy the new one. A party who signed v1 has not signed v2. ## Sign is separate from fill `protocol:fill` and `protocol:sign` are deliberately distinct permissions. The field reality: the technician fills the protocol, but only the *arbetsledare* signs it. Filling records the responder as `ctx.principal`; signing records the signer. Attribution comes from the ambient context, never from the input — you cannot sign as someone else by passing their id. --- # Protocols › Operations & permissions Source: https://substrat.net/engines/protocol/surface.md An engine has **no endpoints**. It exposes operations (invoked through a scope stub) and in-scope functions (called by a vertical inside its own transaction). HTTP, where it exists, is a generated artifact pointed at by the manifest's `api` field. This engine has a third surface the others don't: it **contributes a guard predicate**. ## Operations | Operation | Permission | Does | |---|---|---| | `protocol/define-template` | `protocol:create` | register a new template version | | `protocol/list-templates` | `protocol:read` | list registered templates | | `protocol/instantiate` | `protocol:create` | start an instance on an entity | | `protocol/fill` | `protocol:fill` | append a response (open checklist instances only) | | `protocol/bind-document` | `protocol:bind` | bind vertical content `(ref, hash)` to an open document instance | | `protocol/request-signatures` | `protocol:request-signature` | freeze and send to named parties for external signature | | `protocol/cancel-signatures` | `protocol:request-signature` | withdraw an outstanding request set and thaw | | `protocol/record-signature` | `protocol:record-signature` | record a signature a provider reported | | `protocol/decline-signature` | `protocol:record-signature` | record a refusal or an expiry | | `protocol/sign` | `protocol:sign` | sign in-app; freezes the instance forever | | `protocol/countersign` | `protocol:countersign` | add a second signature on frozen content | | `protocol/void` | `protocol:void` | supersede an instance (never deletes) | | `protocol/get` | `protocol:read` | one instance with responses and signatures | | `protocol/list-for-entity` | `protocol:read` | every protocol on an `EntityRef` | ## In-scope functions The most complete composable surface of any engine here — every operation has a function behind it: ```ts import { instantiateProtocol, requireSigned, PROTOCOL_PERM } from '@substrat-run/engine-protocol'; ``` | Function | Notes | |---|---| | `defineTemplate(ctx, input)` | register a `(key, version)` | | `listTemplates(ctx)` | | | `instantiateProtocol(ctx, input)` | binds to any `EntityRef` | | `fillProtocol(ctx, input)` | append-only; checklist kind only; throws once frozen | | `bindDocument(ctx, input)` | document kind only; rebindable while open | | `requestSignatures(ctx, input)` | **async** — freezes content, opens a request per party | | `recordSignature(ctx, input)` | **async** — an external signatory, at the provider's time | | `declineSignature(ctx, input)` | a refusal or expiry; does **not** thaw the instance | | `cancelSignatureRequests(ctx, input)` | withdraws the set and thaws back to `open` | | `signProtocol(ctx, input)` | **async** — in-app; computes the content hash via Web Crypto | | `countersignProtocol(ctx, input)` | **async** — recomputes and compares the hash | | `voidProtocol(ctx, input)` | | | `getProtocol(ctx, instanceId)` | | | `listProtocolsForEntity(ctx, entity)` | | | `protocolContentHash(template, latest, boundHash?)` | **async** — the hash recipe, exported so you can verify independently. Takes no `ctx`: it hashes a template plus the latest responses (or a bound document hash), nothing scope-bound | | `requireSigned(ctx, entity, templateKey)` | throws unless signed — the completion-guard building block | | `requireCountersigned(ctx, …)` | the counter-signed equivalent | There is deliberately **no `requireAllSigned`**. In the request-driven path an instance reaches `signed` only once every requested party has signed, so "all parties signed" *is* `requireSigned` — a second predicate would read as though it checked something stronger while checking the same thing. The multi-party question is answered by the state machine. None of these check permissions — that is the caller's job. **What they return is parsed, not asserted.** Every value crossing back out of this engine goes through the schema the engine publishes in `src/schemas.ts` — `protocolTemplateRow`, `protocolInstanceRow`, `protocolResponseRow`, `protocolSignatureRow`, `protocolSignatureRequestRow`, and the composites the operations answer with (`signResult`, `requestSignaturesResult`, `protocolDetail`, `protocolSummary`) — the same schema the matching operation points its `output` at. Engine surfaces evolve additively (D-28), but that rule is held by review; the parse is what makes the failure it prevents *loud*. A vertical compiled against 0.3 and running against 0.4, whose row shape moved, used to read a field that had become `null` and render it — now the read throws at the seam. The reads name their columns for the same reason: the SELECT list is derived from the row schema (`columnsOf`), where `SELECT *` would publish whatever the physical table happens to hold. This engine is the sharper case for having the seam at all. A signature attests to a hash over the stored rows, so a row that quietly changed shape is a row whose hash no longer says what the signatory was shown — silence there is worse than a throw. `engines/protocol/src/seam.ts` is one line, `engineSeam('engine-protocol')`; the helpers themselves live in `@substrat-run/contracts`. `signProtocol`, `countersignProtocol`, `requestSignatures`, `recordSignature` and `protocolContentHash` are **async** because Web Crypto is; the rest are synchronous. That `protocolContentHash` is exported at all is the point of the invariant: the recipe is a contract you can replay, not a black box. ## The guard predicate ```ts predicates: { 'protocol/all-signed': allSignedPredicate } ``` The engine **contributes** the predicate; a vertical manifest **wires** it. The engine declares no guard of its own — *what is mandatory when* is vertical policy, and the engine cannot know another module's operations. This is the only genuinely config-shaped surface in any engine. Its config is declared in the **vertical's** manifest, kernel-opaque, and parsed by the predicate that owns it: ```ts export const allSignedGuardConfig = z.object({ templateKey: z.string().min(1), // vertical content: 'tillstandsrapport' entityType: z.string().min(1), // what the protocol hangs on: 'workorder' entityIdFrom: z.string().min(1), // input field holding the id: 'orderId' countersigned: z.boolean().default(false), }); ``` `entityIdFrom` is the trick worth stealing: it **late-binds into the vertical's vocabulary**, so one predicate serves any operation shape without the engine ever knowing the vertical's words. That's how you parameterise engine behaviour without a config bag — see [Composing](./composing#configuration). ## Permissions | Key | Description | |---|---| | `protocol:create` | Define protocol templates and start protocol instances on entities | | `protocol:fill` | Record responses on an open checklist protocol (append-only) | | `protocol:bind` | Bind vertical-owned document content (ref + hash) to an open document protocol | | `protocol:request-signature` | Freeze and request signatures from named parties; also cancels a pending set | | `protocol:record-signature` | Record a signature reported by an external provider | | `protocol:sign` | Sign in-app — freezes it forever (the technician fills, the arbetsledare signs) | | `protocol:countersign` | Counter-sign an already-signed protocol — a second signature on the same frozen content | | `protocol:read` | Read protocol templates, instances, responses, signature requests and signatures | | `protocol:attach` | Attach a document to an instance — the sealed signed PDF a signing connector lands, or a human upload | | `protocol:void` | Void (supersede) a protocol — never deletes | Ten keys for what looks like one workflow, because the separations are the product: fill ≠ sign is the arbetsledare rule, sign ≠ countersign is the customer at pickup, and `void` is a supervisory act. `protocol:attach` is the one key **no operation checks**. The engine declares `attachmentTargets: [{ entityType: 'protocol', readPermission: 'protocol:read', writePermission: 'protocol:attach' }]`, so the platform's own attachment endpoints check it — reading an attachment on an instance takes `protocol:read`, landing one takes `protocol:attach`. Grant it to whatever speaks for the signing connector, and to the roles allowed to upload a document by hand; it is not implied by `protocol:bind`, which binds a content *ref and hash* rather than bytes. `protocol:record-signature` is the odd one and deliberately so: it speaks **for an external provider**, not for a person, and it is held by **no human role in any demo**. A staff signer must not be able to assert that some customer signed with BankID. When ingress lands ([#96](https://github.com/substrat-run/substrat/issues/96), [#97](https://github.com/substrat-run/substrat/issues/97)), granting this key is how a deployment declares what it trusts to speak for a provider — which is why it belongs in the permission diff rather than in a config file. ## Entitlement `entitlementKey: 'protocol'`. Checked per invoke, fails closed. A binary SKU gate, not configuration. --- # Protocols › Events Source: https://substrat.net/engines/protocol/events.md The engine **emits 9 and consumes none** — protocols are driven by operations, not by upstream facts. ```ts events: { emits: [ /* the 9 below */ ], consumes: [], } ``` ## Emitted | Event | v | piiClass | Payload | |---|---|---|---| | `protocol.instantiated` | 1 | none | instance id, template `(key, version)`, entity | | `protocol.response-recorded` | 1 | pseudonymous | instance id, response id, item key, value | | `protocol.content-bound` | 1 | none | instance id, document type, content ref, bound hash | | `protocol.signatures-requested` | 1 | none | content hash, method, parties (label, kind, request id) | | `protocol.signature-declined` | 1 | none | request id, party label, outcome, reason | | `protocol.signatures-cancelled` | 1 | none | instance id, how many were withdrawn, reason | | `protocol.signed` | 1 | pseudonymous | signatory, content hash, **frozen answers** | | `protocol.countersigned` | 1 | pseudonymous | signatory, counter-signatory, hash, **frozen answers** | | `protocol.voided` | 1 | pseudonymous | instance id, entity, previous status, reason | `protocol.signatures-requested` is the connector's dispatch order: it carries the hash, the parties and the content ref, so an executor never needs a read back into the scope to know what to send where. ## The signature events carry the whole document `protocol.signed` and `protocol.countersigned` are **fat events**: the frozen answers travel in the payload alongside the content hash. A downstream consumer — an archival connector, a compliance export — never queries back. This matters more here than elsewhere. The point of the engine is that a signed protocol can be produced *unaltered, years later*. An event carrying the hash but not the answers would force the consumer to re-read tables that may have moved on, which is exactly the join the snapshot discipline exists to prevent. The event **is** the archival record. ## PII and erasure Events referencing a person carry `piiClass: 'pseudonymous'` and that person's `subjectId`. A GDPR erasure crypto-shreds the person while the fact that a signed protocol exists survives. **`subjectId` is not always the acting principal.** On a signature recorded from an external provider it is the *signatory* — a `DataSubjectId` for someone with no account in the system at all. That is what makes an external signature shreddable, and it is why a personnummer must never be used as the signatory reference: it would be `direct` PII written into a row that immutability makes permanent. The provider's own party identifier goes in `evidence_ref`. That split is deliberate and slightly subtle: the *compliance* claim ("this protocol was signed, and here is its hash") outlives the *personal* claim ("by this named individual"). The artifact stays verifiable; the person becomes unidentifiable. ## Evolution rules Payload fields are **frozen once shipped**. New fields may be added; renaming, removing, or retyping one means a `schemaVersion` bump. Before bumping, read the [invoicing engine's `underlag-exported` v2 note](https://substrat.net/engines/invoicing/events.md#versioning): consumer dispatch keys on event **type alone**, so dual-emitting two versions delivers *both* to every consumer of that type. The "deprecation window" the conventions describe isn't actually available until version routing exists. --- # Protocols › Composing & extending Source: https://substrat.net/engines/protocol/composing.md ## Using it as-is ```ts host.registerModule(protocolModule); ``` Then supply template content — the engine ships none. ## Wrapping it with vertical logic The registered operations are default bindings over exported in-scope functions. Your own operations call them in the same transaction, and you own the permission check: ```ts import { instantiateProtocol, PROTOCOL_PERM } from '@substrat-run/engine-protocol'; host.defineOperation('cykel/start-condition-report', async (ctx, input) => { assertAllowed(await ctx.check(PROTOCOL_PERM.create)); return instantiateProtocol(ctx, { templateKey: 'condition-report', entity: input.bike, // any EntityRef — the vertical owns the vocabulary }); }); ``` The vertical declares the `protocol → ` entity relation in *its* manifest — the engine can't know whether protocols hang off work orders, bikes, or apartments — and supplies the template content. ## The completion guard — two forms "Obligatorisk för status" (a work order can't complete until its protocol is signed) is the canonical cross-engine requirement, and there are two ways to wire it. ### Composed, in code ```ts import { requireSigned } from '@substrat-run/engine-protocol'; host.defineOperation('acme/workorder-complete', async (ctx, input) => { assertAllowed(await ctx.check(WORKORDER_PERM.complete)); requireSigned(ctx, input.order, 'self-inspection-electrical'); // throws if not signed return completeWorkOrder(ctx, input); }); ``` The work-order engine knows nothing of protocols, the protocol engine checks its own tables, and the *vertical* wires the two — star topology intact. The `requireSigned` call site **is** the compliance gate. ### Declared, in the manifest ```ts guards: [{ before: 'bike-shop/close-repair', predicate: 'protocol/all-signed', config: { templateKey: 'condition-report', entityType: 'bike', entityIdFrom: 'bikeId' }, }] ``` Same rule, but it appears in the **reviewable manifest diff** — dropping a compliance gate becomes visible rather than a deleted line in a handler. Prefer the declarative form when the rule really is "this operation is blocked until X". Prefer the composed form when the vertical needs to name a moment it actually owns — a bike shop's *pickup*, where the customer accepts the report, is more than the engine's transition, and a guard on `workorder/close` would describe it wrongly. ## Configuration **There is none at registration time.** `ModuleRegistration` has five fields — `manifest`, `migrations`, `operations`, `consumers`, `predicates` — and none is config. There is no `createProtocolModule({...})` factory and no `config` field on the manifest. *Configuration is dynamic; composition is code.* This engine shows the two sanctioned alternatives better than any other: | You want | Use | |---|---| | a parameterised rule | **`guards[].config`** — declared in the vertical's manifest, kernel-opaque, parsed by `allSignedGuardConfig`. Its `entityIdFrom` late-binds into your vocabulary. | | tenant-specific content | **runtime data** — `defineTemplate` puts templates in the tenant's own scope. Which protocols exist is data, not config. | | only part of the engine | `withdraws` in your manifest, then re-offer behind your own operation | | different behaviour around a transition | your own operation calling the in-scope function | | the engine off for a tenant | revoke the `protocol` entitlement | The template mechanism is the lesson worth generalising: content that varies per tenant is **data in the scope**, not options in a constructor. An engine with a `templates:` config array would need a redeploy to add a checklist; `defineTemplate` needs an operation call. ### Adding data to a protocol Never add a column upstream. A vertical needing extra fields adds **its own side table keyed by the engine's instance id**. Another module's tables are private; the stable surface is entity ids, `EntityRef`s, and event payloads. ## Reaching the outside world Nothing in this engine talks to anything external, and it never will: module code may not `fetch`, and connectors handle the outside world. The external signing flow is split across that boundary deliberately: 1. Your vertical calls `requestSignatures(ctx, { instanceId, method: 'scrive', parties })`. That freezes the content, computes the hash **once**, writes a request row per party, and emits a fat `protocol.signatures-requested` carrying everything a connector needs to dispatch — the hash, the parties, the content ref. All inside your transaction. 2. An **executor** outside the scope picks that event off the outbox and makes the HTTP call. It holds platform authority, which module code must never have. 3. Days later the provider reports a signature, and something invokes `recordSignature(ctx, { requestId, signatory, signedAt, contentHash, evidenceRef })`. > **Info — Step 3 has a caller now** > > Both kernel gaps this section used to name are closed, so all three steps run end to end: > > - the **inbound authority seam** exists. `getConnectorScope(connectionId, scopeId)` opens a > scope stub whose authority is the connection's own grants, narrowed by construction to that > tenant and vertical — so a provider's callback acts *as the connection* rather than as a > person it cannot name ([#97](https://github.com/substrat-run/substrat/issues/97)). > - the **ingress** exists, as a capability URL the platform mints per dispatch and mounts on > the control plane, with a poll floor underneath it: no callback URL configured means > reconciliation is poll-only, which is complete and merely slower > ([#96](https://github.com/substrat-run/substrat/issues/96)). > > The [Scrive connector](https://substrat.net/connectors/scrive.md) is the worked example — `reconcileScriveDispatch` > reads the provider's result and calls step 3 across that seam. Two caveats travel with it, > both outside this engine: the consuming vertical must schedule the reconciliation poll, and > BankID-to-sign is off on the testbed account, so the real signing round-trip is still > unverified. > > `recordSignature` remains reachable by **no human role** in any demo, which is the design and > not a gap: `protocol:record-signature` is a key a *connection* holds. A worked example of steps 1 and the shape of 3: [Meridian's anställningsavtal](https://substrat.net/verticals/meridian.md#a-document-the-anstallningsavtal). ### The shape to copy The engine defines what evidence *is* and what must be true of it — the hash matches the frozen content, the signatory matches the request, the timestamp is the provider's. A connector supplies the evidence. What the engine does **not** do is pretend an asynchronous, non-principal signature fits a synchronous, principal-shaped operation: that assumption is what left the freeze window open, and the correction was a new noun (the signature request), not a better comment. --- # Invites › Overview Source: https://substrat.net/engines/invites/index.md `@substrat-run/engine-invites` — how a person joins an organization they are not already in. The engine owns the state machine. It does **not** own the membership: membership is tenant-wide directory state, so accepting an invitation asks the platform to add the member and a privileged executor effects it. The engine's job ends at *"this person said yes"*. ## Why it exists Every multi-tenant product eventually needs to let a customer add their own colleagues, and every one of them builds the same three mistakes: - a lookup that confirms whether an address is already registered, - an invitation that grants access before anyone accepts it, - an unbounded, never-expiring standing offer. Each is a small convenience and a permanent leak. This engine is the shape that avoids them, once, for every vertical. ## The two properties that carry it **Non-enumerable.** Identifiers are stored hashed and never returned — not in a list, not in an event, not in an error message. A non-member, a decline, and an already-invited person are indistinguishable to the sender. The invite surface can never answer *"is this person on the platform?"*. **Accept-required.** An invitation confers nothing until the recipient acts, and accepting re-hashes the identifier they present. A leaked invitation id is therefore not a bearer token for someone else's invitation. ## How these pages are organized - [Domain model & invariants](https://substrat.net/engines/invites/model.md) — the state machine, and what the hashing buys - [Operations & permissions](https://substrat.net/engines/invites/surface.md) — the four operations, and why one of them checks nothing - [Events](https://substrat.net/engines/invites/events.md) — what it emits, including the membership request - [Composing & extending](https://substrat.net/engines/invites/composing.md) — calling it from a vertical --- # Invites › Domain model & invariants Source: https://substrat.net/engines/invites/model.md ## The state machine **Diagram — the invitation state machine**, on `state`, starting at `invited`. - **The main run:** `invited` → *accept* → `accepted` - **Leaves the run:** `invited` → *revoke* → `revoked`. - **Leaves the run:** `invited` → *expire* → `expired`. **Terminal:** `accepted`, `revoked`, `expired`. An operation the machine has never heard of is not forbidden — it is not governed. Absence means "no state gates this", never "denied". This engine declares no lifecycle yet, so the machine above is transcribed from its status enum and guard clauses. Nothing re-checks it — treat it as documentation, not as the contract. Terminal states are terminal. An accepted invitation cannot be revoked, a revoked one cannot be accepted, and an expired one cannot be revived — a new invitation is a new row. ## One table ```sql invites_invitation ( id, org_id, identifier_hash, role_key, state, invited_by, accepted_by, created_at, expires_at, settled_at ) ``` `accepted_by` is null until acceptance, because until then the platform genuinely does not know who the recipient is. That is the point: an invitation is addressed to an identifier, not to a principal. ## The identifier is hashed, and salted per scope The plaintext address is never persisted. An invite table full of addresses is a mailing list, and a breach of it is a breach of everyone who was ever invited anywhere. The salt is the **scope**, not a global constant. A global salt would make one address produce one hash everywhere, so anyone holding the table could correlate a person across tenants — reintroducing exactly what per-tenant identity pools exist to prevent. Identifiers are normalised before hashing, so `A@b.com` and `a@b.com ` are one person. ## Invariants **Re-inviting is indistinguishable from inviting.** Sending to someone who already has an open invitation returns the existing id, in the same shape as a first send. Saying "already invited" would let a sender probe membership one address at a time. **Every failed accept returns one identical error.** Wrong identifier, unknown invitation, already settled, expired — all the same message. Distinguishing them would make the endpoint an oracle. **Expiry is applied on read and on transition**, never by a background sweep. An expired invitation must never be acceptable, and the only moments that matters are when someone looks or someone acts. A sweep would be a second source of truth for the same fact. **Open invitations are bounded** — 25 per sender per organization, 14 days by default. The limit counts *open* invitations rather than lifetime sends, or a busy admin would eventually be locked out forever. --- # Invites › Operations & permissions Source: https://substrat.net/engines/invites/surface.md ## Permissions | Key | Holder | |---|---| | `invites:send` | whoever may add people to an organization | | `invites:read` | whoever may see pending invitations and their state | | `invites:revoke` | whoever may withdraw an unaccepted invitation | ## Operations ``` invites/send { orgId, identifier, roleKey, ttlMs? } → { id } invites/accept { invitationId, identifier } → Invitation invites/list { orgId, limit?, cursor? } → Page invites/revoke { invitationId } → void ``` Each is the thin binding the engine convention requires: a permission check plus one exported in-scope function. `invites/list` answers a **page**, newest first, walked by `id` — a ULID, so it carries the same instant `created_at` does and is unique, which a cursor must be. The in-scope `listInvites` stays unpaged: a vertical composing it is reading one org's invitations to decide something, not rendering a table. ## In-scope functions The composable surface. A vertical calls these **inside its own operation**, in one transaction — this is how you extend the engine without forking it. For every one of them except `acceptInvite`, the caller owns the permission check; **`acceptInvite` must not be gated**, for the reason below, and putting it behind a permission key is how you lock out the invitees the invitation was issued to. ```ts sendInvite(ctx, { orgId, identifier, roleKey, ttlMs? }) → Promise<{ id }> acceptInvite(ctx, { invitationId, identifier }) → Promise listInvites(ctx, orgId) → Invitation[] revokeInvite(ctx, invitationId) → void expireOverdue(ctx, orgId) → void hashIdentifier(scopeSalt, identifier) → Promise effectiveStateOf(state, expiresAt, now) → InviteState ``` Notes worth knowing: - `sendInvite` hashes the identifier **before it is persisted or used in a lookup** — the plaintext never reaches storage and never appears in a `WHERE` clause. It also rate-limits open invitations per sender per organization, and answers `{ id }` and nothing about whether the recipient already exists, is already a member, or declined before — that uniformity is what keeps the surface non-enumerable. - `acceptInvite` checks no permission (below) and re-hashes the presented identifier to compare. It emits `member.add-requested`; it does **not** write the membership. - `expireOverdue` is called on the **write** paths inside the engine — `sendInvite`, and the accept that refuses an overdue invitation — so an overdue invitation is never acceptable even if no sweep has run. It is exported for a host that wants to run it on a schedule as well. - `listInvites` does **not** call it: a read settles nothing (#964). It renders the state instead, through `effectiveStateOf`, so a still-`invited` invitation past `expires_at` reports `expired` while its row is untouched and its `settled_at` is still `null`. Call the same list twice and you get the same answer, having written nothing either time. - `effectiveStateOf(state, expiresAt, now)` is that one comparison, exported so a vertical folding `listInvites` into its own read asks the engine's question rather than re-deriving it. It touches `invited` rows only: an `accepted`, `revoked` or already-swept `expired` invitation comes back exactly as stored, `settled_at` included, however long ago its deadline passed. - `hashIdentifier` is exported because a *host* building an accept link needs the same comparison input. It is Web Crypto (`globalThis.crypto`), never a hand-rolled digest, and it is salted with the scope id — one address hashes differently in every tenant. None of these check permissions themselves. For every one that takes a `ctx` except `acceptInvite` that is the caller's job, by design; for `acceptInvite` there is no check to make. `hashIdentifier` and `effectiveStateOf` take no `ctx` at all — they are pure, and there is nothing there to check. ## `invites/accept` checks no permission Deliberately, and it is the one thing on this page worth arguing about. The recipient is not yet a member of anything, so there is no grant they could hold. A permission check would either be vacuous or would require granting access *before* acceptance — which is precisely what accept-required exists to prevent. The invitation is the authority, and it is proven rather than asserted: accept re-hashes the identifier the caller presents and compares it to the stored hash. An invitation id alone is not enough, so a leaked id is not a bearer token. ## What comes back `Invitation` is the row **minus the identifier hash**. The hash never crosses the engine boundary, because a leaked hash lets its holder confirm an address offline — which is the enumeration the whole design exists to prevent. ```ts type Invitation = { id: string; org_id: string; role_key: string; state: 'invited' | 'accepted' | 'revoked' | 'expired'; invited_by: string; accepted_by: string | null; created_at: string; expires_at: string; settled_at: string | null; }; ``` --- # Invites › Events Source: https://substrat.net/engines/invites/events.md ## Emitted | Type | Version | When | |---|---|---| | `invites.sent` | 1 | a new invitation is recorded | | `invites.accepted` | 1 | the recipient accepts | | `invites.revoked` | 1 | an unaccepted invitation is withdrawn | | `member.add-requested` | 1 | on acceptance — the membership request | ## Consumed **None.** `consumes` is empty: this engine is a pure producer. An invitation is started by a call, never by another module's event. All payloads are `piiClass: 'none'` and **contain no identifier**. The event spine outlives the row it describes, so an address leaked here is leaked for as long as history is kept. `invites.accepted` carries `{ invitationId, orgId, roleKey, principal }`. The `principal` is who accepted — a ULID, so it names nobody outside the platform, and a vertical creating its own record for that person needs it. Without it the event would describe an acceptance by no one, which is precisely what the first vertical to consume it discovered. ## `member.add-requested` is the interesting one The engine cannot write a membership tuple. Membership is tenant-wide directory state, outside this scope's transaction — so the engine *asks*, and a privileged [executor](https://substrat.net/concepts/events.md#the-connector-seam) effects it. ```ts { principal, // who accepted orgId, // which organization tenantId, roleKey, // what the invitation offered invitationId, // provenance } ``` The payload is deliberately **fat**: the executor must never need a cross-module read to act on it. This is also why acceptance is atomic in the way that matters. `ctx.emit` commits with the engine's own write, so an accept that fails leaves no event and therefore no membership. An in-scope cross-database write could not offer that — it could land in the directory and then be orphaned by the scope's rollback. --- # Invites › Composing & extending Source: https://substrat.net/engines/invites/composing.md ## From a vertical The engine exports its entity registry as `invitesEntities`; pass it to your `defineOperations(entities, PERMISSIONS, [invitesEntities])` so an operation that narrows to, or emits about, an invitation names the engine's entity rather than retyping it (see [Composing engines](https://substrat.net/concepts/model.md#composing-engines)). Call the exported in-scope functions inside your own operation, in the same transaction, having checked your own permission first: ```ts import { sendInvite } from '@substrat-run/engine-invites'; const inviteColleagueOp: OperationHandler<{ email: string }, { id: string }> = async (ctx, input) => { assertAllowed(await ctx.check(MYAPP_PERM.manageTeam)); return sendInvite(ctx, { orgId: myOrgFor(ctx), identifier: input.email, roleKey: 'colleague', // YOUR vocabulary }); }; ``` The engine never decides who may invite, what a role means, or how the invitation reaches the recipient. Those are the vertical's. ## Delivering the invitation The engine records that an invitation exists; it does not send email. That is deliberate — delivery is an effect on the outside world, so it belongs in a connector, not in module code (which cannot reach the network at all). Consume `invites.sent` in a connector and send whatever your product sends. The event carries no identifier, so the connector resolves the recipient from your own records — which keeps the address out of the spine. ## A worked example The shape a vertical takes around it: compose `sendInvite` inside your own `/invite-` operation, keep the invitee's name and party ref in your own table keyed by the invitation id, and create your membership row from a consumer on `invites.accepted`. No demo in the repo composes this engine today — the racket-club demo that did was removed — so the engine's own suite (`engines/invites/test/`) is the working reference for each call. ## Extending it **Extra states** belong in your vertical as substates, not as forks of the machine. The engine's four states are about *whether the offer stands*; anything about your onboarding flow is yours. **Extra data** on an invitation goes in your own table keyed by the invitation id. The engine's table is private, as every module's is. ## What not to do **Do not report whether an address is already invited or already a member.** Every affordance that distinguishes those cases turns the invite surface into a membership oracle. If your UI wants to say "already on the team", derive it from your own membership list for people the caller can *already see* — never from the invite path. --- # Absence › Overview Source: https://substrat.net/engines/absence/index.md `@substrat-run/engine-absence` — approved absence as an **append-only entry ledger over an opaque subject**. Balances are folds, approval is the only path onto the ledger, and the engine deliberately knows nothing about who its subjects are, how leave accrues, or which days count — all of which are the vertical's. ## At a glance | | | |---|---| | **Package** | `@substrat-run/engine-absence` | | **Entitlement key** | `absence` | | **Owns** | the append-only ledger, balance-as-a-fold, the per-type balance floor, the request approval state machine, stale-request expiry | | **Emits** | 6 events, `absence.requested` → `absence.decided` ([events](./events)) | | **Consumes** | nothing — it is a source, not a sink | | **Permissions** | 4 (`absence:read` · `request` · `approve` · `configure`) | | **Status** | product seed (0.x) — surfaces change until the first vertical ships | ## What it owns - **The ledger is append-only, always.** An accrual, a booking, a correction, a carryover, or a reversal is a **new entry** — never an edit, never a delete. A mistake is corrected by a compensating entry, so the record of what was believed when survives. - **Balance is a pure fold.** `balanceAsOf(subject, leaveType, asOf?)` sums signed deltas over entries effective by that date. There is no stored counter to drift, no cache to invalidate, nothing to reconcile. - **Only an approved request books.** The state machine — `requested → approved | rejected`, with `requested | approved → cancelled` — cannot skip, and the `booking` and `reversal` entry kinds are mintable **only** through `decideAbsence` and `cancelAbsence`. "The ledger moves only through approval" is a construction, not a review comment. - **The floor is the engine's, per leave type.** A booking that would fold the balance below the type's floor is rejected at decision time — not by the UI. Floor `0` refuses overdraft; a **negative floor admits advance leave** (*förskottssemester*) with no code change. - **The subject is opaque.** Every write names a `subject`: an `EntityRef` the vertical provides (an employee, a plannable crew resource) plus the `DataSubjectId` that keys crypto-shredding. The engine never dereferences the ref and **never owns a directory**. - **Stale requests expire under a system actor.** The manifest declares an `absence/expire-stale` schedule: a leave still `requested` after its start date is cancelled by the platform sweep, attributed to `{ system: '@substrat-run/engine-absence' }` — never to a manager who never looked at it. ### The property worth understanding **Dates here are calendar days, inclusive on both ends** — deliberately unlike the [booking engine's](https://substrat.net/engines/booking/index.md) half-open instants. Booking allocates physical time; absence covers human days. The `days` decimal on a request is **vertical-computed** (a Monday–Friday request may be `5`, or `4` after excluding a red day): the engine folds it, it never derives it. Correspondingly, `availability()` answers *"an approved absence covers this date"* — never *"this many hours free"*. Composing that verdict with a holiday calendar is the vertical's last mile. ## What it will not do - **No directory.** No employee table, no names, no employment terms, no notion of who reports to whom. Existence checks on the subject happen in *your* operation, before the engine call. - **No leave-type semantics.** The engine stores a key, a floor, and an active flag. Labels, statutory day counts, what *VAB* or *baja* mean, per-country divergence — all vertical vocabulary ([composing](./composing)). - **No accrual formulas, no carryover caps.** Accruals and carryovers are entries you record; the rules that generate them are yours. - **No calendars.** Weekends, red days, and public holidays never enter the engine. - **No scheduling.** "Is this person double-booked on a shift" is a different seam (interval capacity — the booking engine, or a future scheduling engine). This engine answers availability *per an approved ledger*, nothing more. ## Where it came from The ledger shape was written **vertical-first** inside the Meridian HR demo, to that spec's own extraction plan: freeze the invariants as plain module code, and extract the engine only when a *second consumer with a different shape* arrives. It did — field-crew resource planning, where the subject is a plannable `resource` rather than an employee — and the extraction moved the invariants here while Meridian kept its vocabulary and screens. The subject-opacity line above is what made that composition possible, and it is load-bearing: it is why the same engine serves an HR product and a route planner without either forking it. --- # Absence › Domain model & invariants Source: https://substrat.net/engines/absence/model.md ## Tables ```sql absence_leave_types ( key TEXT PRIMARY KEY, -- vertical vocabulary: 'vacation' | 'sick' | 'vab' floor TEXT NOT NULL DEFAULT '0', -- decimal; balance may not fold below this active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL ) absence_ledger ( -- APPEND-ONLY. No UPDATE. No DELETE. Ever. id TEXT PRIMARY KEY, -- ulid() subject_type TEXT NOT NULL, -- the opaque ref, stored as its pair subject_id TEXT NOT NULL, data_subject_id TEXT NOT NULL, -- keys crypto-shredding on every event leave_type_key TEXT NOT NULL, entry_kind TEXT NOT NULL, -- accrual | booking | correction | carryover | reversal delta TEXT NOT NULL, -- SIGNED decimal days effective_date TEXT NOT NULL, -- the date the entry counts from request_id TEXT, -- set on 'booking' and 'reversal' note TEXT, created_by TEXT NOT NULL, created_at TEXT NOT NULL ) absence_requests ( id TEXT PRIMARY KEY, -- ulid() subject_type TEXT NOT NULL, subject_id TEXT NOT NULL, data_subject_id TEXT NOT NULL, leave_type_key TEXT NOT NULL, start_date TEXT NOT NULL, -- calendar date, INCLUSIVE end_date TEXT NOT NULL, -- calendar date, INCLUSIVE days TEXT NOT NULL, -- positive decimal, vertical-computed status TEXT NOT NULL, -- requested | approved | rejected | cancelled note TEXT, decided_by TEXT, decided_at TEXT, created_by TEXT NOT NULL, created_at TEXT NOT NULL ) ``` Three deliberate shapes: - **The subject is a stored pair** (`subject_type`, `subject_id`) — an `EntityRef` the engine can never dereference. Two subjects with the same id but different types (an `employee` and a `resource`) hold fully independent ledgers. - **`data_subject_id` is denormalized onto every row** so a later transition (decide, cancel, expire) can emit a correctly-keyed pseudonymous event without the caller re-supplying it. The vertical decides what that id shreds to. - **Money-style decimals** (`delta`, `days`, `floor`) are strings folded with `addDecimal`/`compareDecimal` — never floats. ## The five entry kinds | Kind | Signed delta | Minted by | Meaning | |---|---|---|---| | `accrual` | usually `+` | `recordEntry` | entitlement granted (annual grant, monthly accrual) | | `carryover` | `+`/`−` | `recordEntry` | balance carried across a period boundary | | `correction` | `+`/`−` | `recordEntry` | the administrator's compensating fix — never an edit | | `booking` | `−` | **`decideAbsence` only** | an approved request consuming balance | | `reversal` | `+` | **`cancelAbsence` only** | the compensating undo of a booking | `recordEntry`'s input schema refuses `booking` and `reversal` outright — the request flow is the only mint for them, which is what turns "only an approved request touches the ledger" from a convention into a construction. ## The invariants - **Append-only** — nothing in the engine issues `UPDATE` or `DELETE` against `absence_ledger`. Corrections compensate; history survives. - **Balance is a fold**: `balanceAsOf` = Σ `delta` over entries with `effective_date <= asOf`. Pure, deterministic, no stored aggregate. - **No fold below the floor** — approval re-folds the balance *at decision time* (the world may have changed since the request) and rejects a booking that would breach the leave type's floor. - **The state machine cannot skip** — only a `requested` row can be decided; only `requested` or `approved` can be cancelled; `rejected` and `cancelled` are terminal. - **A cancelled approval reverses, never edits** — cancelling an `approved` request writes a `reversal` entry mirroring the booking's delta, in the same transaction as the status change. - **Every mutation emits a fat event; every operation checks a permission.** ## The state machine **Diagram — the absence request state machine**, on `status`, starting at `requested`. - **The main run:** `requested` → *approve* → `approved` → *cancel* → `cancelled` - **Leaves the run:** `requested` → *reject* → `rejected`. - **Rejoins:** `requested` → *cancel* → `cancelled`. **Terminal:** `rejected`, `cancelled`. An operation the machine has never heard of is not forbidden — it is not governed. Absence means "no state gates this", never "denied". This engine declares no lifecycle yet, so the machine above is transcribed from its status enum and guard clauses. Nothing re-checks it — treat it as documentation, not as the contract. **Expiry** is the platform's date-triggered seam: the manifest declares an `absence/expire-stale` schedule (daily), so a request still `requested` past its `start_date` is cancelled by the platform sweep under `{ system: '@substrat-run/engine-absence' }`. The pass is idempotent (only `requested` rows past their start) and paged (a bounded batch per pass), so a backlog drains across passes rather than blowing one invoke. ## Days, not instants Requests span **inclusive calendar dates** and carry a **vertical-computed** `days` decimal. The engine never counts days from the date range — a Swedish vertical excludes red days, a Spanish one counts differently, and half-days are decimals — so the range answers *when* and the decimal answers *how much*. `availability()` expands approved ranges to per-date coverage; hours-granular absence is deliberately out of v0. --- # Absence › Operations & permissions Source: https://substrat.net/engines/absence/surface.md ## Operations Registered bindings, each one a permission check plus a call into the in-scope function below. | Operation | Permission | Does | |---|---|---| | `absence/configure-leave-type` | `absence:configure` | register/update a leave type's key, floor, active flag | | `absence/list-leave-types` | `absence:read` | list registered types | | `absence/record-entry` | `absence:configure` | append an `accrual` / `correction` / `carryover` entry | | `absence/request` | `absence:request` *(per subject)* | file a request for a subject | | `absence/decide` | `absence:approve` | approve (books, floor-checked) or reject | | `absence/cancel` | `absence:approve`, **or** `absence:request` *(per subject)* for one's own still-`requested` row | withdraw or cancel; an approved cancel writes the reversal | | `absence/expire-stale` | `absence:approve` | cancel requests past their start date — the declared schedule's target | | `absence/balance` | `absence:read` *(per subject)* | balance-as-of fold | | `absence/availability` | `absence:read` *(per subject)* | per-date coverage over a range | | `absence/list-requests` | `absence:read` *(per subject when filtered, node otherwise)* | requests, by subject and/or status | | `absence/list-entries` | `absence:read` *(per subject)* | the raw ledger for a subject | Checks marked *(per subject)* pass the subject's `EntityRef`, so a principal holding an **entity-narrowed grant** on their own ref — an employee, a fältarbetare with a login — reaches their own balance, requests, and withdrawals while holding no role at all. Node holders (managers, planners, HR) pass the same checks unnarrowed. ## In-scope functions The composable surface. A vertical calls these **inside its own operation and its own permission check**, in one transaction — this is how you extend the engine without forking it. ```ts configureLeaveType(ctx, { key, floor?, active? }) → LeaveType listLeaveTypes(ctx) → LeaveType[] recordEntry(ctx, { subject, leaveTypeKey, entryKind, // accrual|correction|carryover delta, effectiveDate, note? }) → AbsenceEntry requestAbsence(ctx, { subject, leaveTypeKey, startDate, endDate, days, note? }) → AbsenceRequest decideAbsence(ctx, { requestId, decision, note? }) → { request, booking: AbsenceEntry | null } cancelAbsence(ctx, { requestId, reason? }) → { request, reversal: AbsenceEntry | null } expireStaleRequests(ctx) → { expired: number } balanceAsOf(ctx, { subject, leaveTypeKey, asOf? }) → string // pure fold availability(ctx, { subject, from, to }) → { days, requests } listRequests(ctx, { subject?, status? }) → AbsenceRequest[] listEntries(ctx, { subject, leaveTypeKey? }) → AbsenceEntry[] entriesInWindow(ctx, { from, to, entryKind? }) → AbsenceEntry[] // in-scope only, no binding ``` Every **write** takes a `subject`: ```ts subject: { ref: EntityRef, // your noun — { entityType: 'employee' | 'resource' | …, entityId } dataSubjectId: DataSubjectId, // keys erasure on every event this write emits } ``` **Reads** take the bare `ref` — no erasure key is needed to look. **What they return is parsed, not asserted.** Every value crossing back out of this engine goes through the schema the engine publishes in `src/schemas.ts` — `leaveType`, `absenceEntry`, `absenceRequest`, `absenceDay` — the same schema the matching operation points its `output` at. Engine surfaces evolve additively (D-28), but that rule is held by review; the parse is what makes the failure it prevents *loud*. A vertical compiled against 0.3 and running against 0.4, whose row shape moved, used to read a field that had become `null` and render it — now the read throws at the seam. The reads name their columns for the same reason: the SELECT list is derived from the row schema (`columnsOf`), where `SELECT *` would publish whatever the physical table happens to hold. The **fold** is parsed too, and here that is the point. A balance is the sum of every `delta` in the ledger, so a `delta` that drifted is the one value that would otherwise cross as a *number nobody questions* — wrong on a screen, never a throw. `balanceAsOf` parses each `delta` as it folds and the answer as a signed decimal, and `availability` parses the day series it computes. `engines/absence/src/seam.ts` is one line, `engineSeam('engine-absence')`; the helpers themselves live in `@substrat-run/contracts`. Notes worth knowing: - `decideAbsence(approve)` **re-folds at decision time** and enforces the leave type's floor then — the request may be days old and the world may have moved. - `cancelAbsence` on an `approved` request writes the compensating `reversal` in the same transaction as the status change; on a `requested` row it touches no ledger. - `entriesInWindow` has **no operation binding** — it exists for vertical compositions (a payroll export collecting the period's bookings; a planner sweeping a route window) that gate it behind their own permission. - `availability` clamps to the queried range and reports **approved** coverage only — a merely-requested absence never shows as covered. ## Permissions `absence:read` · `absence:request` · `absence:approve` · `absence:configure` - **`absence:request`** is the self-service key: granted entity-narrowed on a subject's own ref, it lets that person file — and withdraw — their own requests, and nothing else. - **`absence:approve`** covers deciding, cancelling an approved absence, and the expiry sweep — it is also the single permission the declared schedule's system principal is granted, which is exactly what appears in the permission diff. - **`absence:configure`** covers leave-type policy and direct ledger writes (`recordEntry`) — the administrator's escape hatch, deliberately floor-unchecked. - **`absence:read`** unnarrowed is the planner's and manager's view; narrowed, it is "my balance". --- # Absence › Events Source: https://substrat.net/engines/absence/events.md ## Emitted | Event | When | |---|---| | `absence.leave-type-configured` | a leave type's key/floor/active registered or updated | | `absence.entry-recorded` | any ledger entry lands — accrual, correction, carryover, booking, reversal | | `absence.requested` | a request is filed | | `absence.decided` | a request is approved (carries the booking) or rejected | | `absence.cancelled` | a request is withdrawn or an approved absence cancelled (carries the reversal id) | | `absence.expired` | the schedule cancelled a request left unapproved past its start date | Consumes nothing. ## Payload contracts All payloads are **fat**: they carry the subject ref, the leave-type key, the ids and the deltas a consumer needs, so no consumer ever reads back across the module boundary. - `absence.entry-recorded` — `{ entryId, subject, leaveTypeKey, entryKind, delta, effectiveDate, requestId }`. Note that **bookings and reversals emit this too** (from inside `decideAbsence`/`cancelAbsence`): a consumer that only cares about ledger movement can subscribe to this one event and see every delta, whatever minted it. - `absence.requested` — `{ requestId, subject, leaveTypeKey, startDate, endDate, days }`. - `absence.decided` — `{ requestId, subject, leaveTypeKey, decision, bookingId }`, plus `days`/`startDate`/`endDate` on approval. One transition, one event — there is no separate `absence.booked`; the booking's id is on the decision. - `absence.cancelled` — `{ requestId, subject, leaveTypeKey, priorStatus, reversalId }`. `priorStatus` tells a consumer whether balance moved (`approved` → a reversal exists) or not (`requested` → `reversalId: null`). - `absence.expired` — `{ requestId, subject, startDate }`, emitted under the system actor, which is how an audit reader distinguishes the schedule from a person. ## PII and the subject key Every subject-bearing event is `piiClass: 'pseudonymous'` with `subjectId` set to the **vertical-supplied `DataSubjectId`** stored on the row — the erasure key travels with every fact about a person, so crypto-shredding that subject shreds their absence trail in one keyed sweep while the scope's aggregate numbers survive. `absence.leave-type-configured` is `piiClass: 'none'` — policy, not people. The engine never invents that id: the vertical passes it on every write (Meridian passes the employee id its whole hr.\* spine already shreds on; a route planner passes its resource id). Choosing an id that actually maps to the person is the vertical's responsibility, stated here because nothing downstream can fix a wrong choice. ## Versioning Every event is emitted at `schemaVersion: 1`. Payload fields are **frozen once shipped**: additions are fine, renames/removals/retypes mean a `schemaVersion` bump — the platform-wide rule, restated because ledger events are exactly the kind downstream payroll and reporting consumers quietly grow to depend on. A bump is a **replace, not a dual-emit**: consumer dispatch keys on the event type alone, so two versions in flight would deliver *both* to every consumer of that type. Read [the invoicing engine's `underlag-exported` v2 note](https://substrat.net/engines/invoicing/events.md#versioning) before bumping anything here. --- # Absence › Composing & extending Source: https://substrat.net/engines/absence/composing.md ## Using it as-is Register it and the default bindings work: ```ts import { absenceModule } from '@substrat-run/engine-absence'; host.registerModule(absenceModule); ``` That gets you leave-type registration, the request → approve flow, balances, and availability under the engine's own permission keys. Most verticals wrap `request` and the reads instead, because that is where their directory and their vocabulary live. ## The subject boundary **The engine never owns a directory.** Every write takes `subject: { ref, dataSubjectId }`, and the engine stores the pair without ever dereferencing it. That one line is what lets the same engine serve very different verticals: - an HR product binds `{ entityType: 'employee', entityId }` from its own employee table, with the employee id as the erasure key; - a field-service planner binds `{ entityType: 'resource', entityId }` from its resource register — where a *resource* is a plannable unit, not an identity: one human can be two resources, and a resource with no login still gets sick. Two consequences you own: 1. **Existence checks are yours.** The engine accepts any ref; verify the employee/resource exists in *your* operation, before the engine call. 2. **The `dataSubjectId` must actually map to the person.** It keys crypto-shredding on every event the write emits; choose the id your erasure path already shreds on. ## The vocabulary split The engine's `absence_leave_types` row is deliberately skeletal: **key, floor, active**. Everything a human sees lives in *your* table, keyed by the same string: ```ts // Your operation, one transaction: your vocabulary row + the engine's policy row. host.defineOperation('hr/define-leave-type', async (ctx, input) => { assertAllowed(await ctx.check(PERM.configure)); upsertMyLeaveTypeVocabulary(ctx, input); // label, kind, statutory days — yours configureLeaveType(ctx, { key: input.key }); // key + floor — the engine's return …; }); ``` Per-country divergence is data, not forks: a Swedish scope registers `vacation` with 25 statutory days in its vocabulary and *VAB* as a type; a Spanish scope registers 22 and *baja* — same engine, same keys where they overlap, different vocabulary rows per scope. **The floor is the one policy knob the engine holds.** `floor: '0'` (the default) refuses overdraft; `floor: '-25'` is *förskottssemester* — advance vacation up to 25 days — enforced at decision time with no vertical code. ## Accrual, carryover, and the calendar — yours The engine records entries; it never generates them. The compositions: - **Accrual rules** — your schedule or your operation calls `recordEntry(…, entryKind: 'accrual')` per your formula (annual grant, monthly fraction, tenure tiers). - **Year-end carryover** — a vertical-declared schedule that folds each subject's balance and writes `carryover` entries per your caps. The trigger seam (a manifest schedule) is the same one the engine itself uses for expiry. - **Day counting** — the `days` on a request is yours to compute (weekends, red days, half-days), which is also why the engine never checks it against the date range. - **The availability verdict is half of the planner's answer.** `availability(ctx, { subject, from, to })` returns dates covered by approved absence; compose it with your holiday calendar for "is Hugo actually out Monday": ```ts const { days } = availability(ctx, { subject: resourceRef(id), from, to }); const out = new Set(days.map((d) => d.date)); const holidays = myRedDays(ctx, from, to); // your scope's calendar return dates.filter((d) => !out.has(d) && !holidays.has(d)); ``` ## Composing the window reads `entriesInWindow(ctx, { from, to, entryKind: 'booking' })` is in-scope only — no operation binding — because its two known callers are vertical compositions with their own gates: a **payroll export** collecting the period's booked absence next to approved expenses, and a **planner** sweeping a route window across every resource. Wrap it behind your own permission; the engine deliberately does not decide who may see everyone at once. ## What extension is *not* The engine registers one frozen constant — no options object, no config field. When you need different behaviour, you compose (your operation calling in-scope functions), store (vocabulary as scope data), or gate (the entitlement flag). If you find yourself wanting to fork it — a sixth entry kind, an approval step the machine doesn't have — that is a boundary finding worth filing, not patching around: the state machine and the entry-kind mint are exactly the invariants the engine exists to keep identical everywhere. ## The extraction handoff, if you were vertical-first If you already run an absence ledger as vertical tables, adopt the engine the way the seeding HR vertical did: 1. register `absenceModule` **before** your module (registration order = migration order, so the engine's tables exist when your journal runs); 2. a one-time extraction-handoff migration (`boundary-lint-allow R5` — the one sanctioned write to another module's schema) copies your ledger and requests into `absence_*`, mapping your subject id into the (`subject_type`, `subject_id`, `data_subject_id`) triple, and registers your leave-type keys; 3. your operations become compositions of the in-scope functions — behind your unchanged HTTP surface, if you keep a thin mapping layer. --- # Metering › Overview Source: https://substrat.net/engines/metering/index.md `@substrat-run/engine-metering` — billable usage as an **append-only, idempotent meter ledger whose closed periods are frozen billing evidence**. It counts what happened — tokens, requests, bytes stored — and deliberately knows nothing about what any of it costs: pricing is the vertical's. ## At a glance | | | |---|---| | **Package** | `@substrat-run/engine-metering` | | **Entitlement key** | `metering` | | **Owns** | the append-only usage ledger, idempotent ingest, the counter/gauge aggregation split, the period-close journal and its horizon | | **Emits** | 3 events, `metering.usage-recorded` → `metering.period-closed` ([events](./events)) | | **Consumes** | nothing — it is a source, not a sink | | **Permissions** | 4 (`metering:read` · `record` · `configure` · `close`) | | **Status** | product seed (0.x) — surfaces change until the first vertical ships | ## What it owns - **The ledger is append-only, always.** A usage entry is never edited or deleted; an over-recorded counter is corrected by a **compensating entry** with a negative delta, so the record of what was believed when survives — the same discipline as every other ledger on the platform. - **Ingest is idempotent by construction.** Every `recordUsage` names a caller-supplied **dedupe key**, unique per meter. A replay with the same key and quantity returns the existing entry — no second row, no second event, no double bill. The same key with a *different* quantity throws: that is an upstream bug, and swallowing it would hide exactly the defect the key exists to catch. - **Counters and gauges aggregate differently, on purpose.** A *counter* (tokens, requests) is a flow you **sum**; a *gauge* (bytes stored, seats) is a level you **sample** — its window aggregate is the max in-window, and a gauge with no samples in a window **carries its last level forward**. The kind lives on the meter definition and is frozen after creation. - **A closed period is evidence, not a snapshot.** `closePeriod` freezes a half-open `[from, to)` window into immutable per-meter lines and emits one fat event. Closes are monotonic, and the latest closed `to` is a **hard horizon**: no new entry may land behind it, so a closed period's lines stay reproducible from its entries forever. - **Entries may carry an opaque attribution ref.** An optional `subject` (`EntityRef` — a builder project, a message, whatever the vertical's noun is) tags an entry for bill-splitting and filtering. The engine never dereferences it. ### The property worth understanding **This is the *billable* metering plane, and it is deliberately not the only one.** The platform's high-volume telemetry — per-request, per-subrequest, per-egress-verdict — rides Analytics Engine datapoints: cheap, unbounded, lossy-tolerant, right for cost attribution and abuse detection, and disqualified from invoices by its sampling. This engine is the other plane: durable, transactional, in the scope's own SQL, auditable — because an invoice needs evidence a customer can dispute. If you are metering something high-volume for billing, **pre-aggregate before recording** (one entry per hour or day, with the bucket id as the dedupe key) rather than mirroring the firehose into the ledger ([composing](./composing)). ## What it will not do - **No prices, no currency, no plans.** `metering.period-closed` carries *unpriced* quantities. The vertical maps meter keys to rates and feeds [invoicing](https://substrat.net/engines/invoicing/index.md) — the day this engine emits a `Money`, it has crossed into vertical vocabulary. - **No close schedule.** *When* to close a period — monthly, weekly, on demand — is billing policy, which is vertical vocabulary. The vertical calls `closePeriod` from its own schedule or operation. - **No collection.** The engine records what callers hand it; observing the usage (an AI turn's token count, a storage sample) is the vertical's or platform's job. - **No per-subject bill splitting in closed lines (v0).** Lines aggregate per meter across all subjects; per-subject detail stays queryable from the entries. Grouping lines by `(meter, subject)` is a plausible additive extension when a vertical needs the *bill itself* split. - **No spend caps or rate limits.** Those are enforcement, live on the platform plane, and must not depend on a ledger a scope owner could starve. ## Where it came from Unlike its siblings, this engine was **not extracted from a vertical** — it was built against a named first consumer with the second in sight, and the decision log carries that honestly. The platform's billing doctrine (*meter everything, bill the few*) deferred metering "until a vertical meters something"; the **builder portal's token economy** is that trigger — AI turns whose token usage must be recorded so it can be charged for. Vertical AI features are the anticipated second consumer. The design decisions — the two planes, the dedupe contract, the horizon — are pinned in `docs/engines/metering.md` in the repository. --- # Metering › Domain model & invariants Source: https://substrat.net/engines/metering/model.md The engine exports its registry as `meteringEntities` — `metering-meter`, `metering-entry` and `metering-period` — so a vertical composes it by passing the registry to `defineOperations` and never retypes a shape (see [Composing engines](https://substrat.net/concepts/model.md#composing-engines)). ## Tables ```sql metering_meters ( key TEXT PRIMARY KEY, -- vertical vocabulary: 'ai.tokens.input' | 'storage.bytes' | … kind TEXT NOT NULL, -- counter | gauge — FROZEN after creation unit TEXT NOT NULL, -- 'tokens' | 'bytes' | 'requests' — FROZEN after creation description TEXT, active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL ) metering_entries ( -- APPEND-ONLY. No UPDATE. No DELETE. Ever. id TEXT PRIMARY KEY, -- ulid() meter_key TEXT NOT NULL, qty TEXT NOT NULL, -- decimal string; SIGNED for counters, >= 0 for gauges subject_type TEXT, -- optional opaque attribution ref, stored as its pair subject_id TEXT, occurred_at TEXT NOT NULL, -- UTC instant, normalized to ms precision dedupe_key TEXT NOT NULL, -- caller-supplied idempotency key note TEXT, created_by TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE (meter_key, dedupe_key) -- THE invariant: one observation, one row ) metering_periods ( -- append-only close journal; monotonic, non-overlapping id TEXT PRIMARY KEY, -- ulid() from_at TEXT NOT NULL, -- half-open [from, to) to_at TEXT NOT NULL, -- MAX(to_at) = the close horizon closed_by TEXT NOT NULL, closed_at TEXT NOT NULL ) metering_period_lines ( -- the frozen aggregates; unpriced by design id TEXT PRIMARY KEY, period_id TEXT NOT NULL, meter_key TEXT NOT NULL, kind TEXT NOT NULL, -- snapshot of the meter at close unit TEXT NOT NULL, qty TEXT NOT NULL, entry_count INTEGER NOT NULL -- 0 = a gauge's carried-forward level ) ``` Three deliberate shapes: - **The dedupe key is unique *per meter*, not globally** — one builder turn records `ai.tokens.input` and `ai.tokens.output` under the same turn id. - **Instants are UTC ISO-8601 normalized to millisecond precision** on the way in, so lexicographic order *is* chronological order — mixed precision (`…T00:00:00Z` vs `…T00:00:00.000Z`) would silently break every window comparison. - **Quantities are money-style decimal strings** folded with `addDecimal`/`compareDecimal` — never floats. ## The two meter kinds | Kind | Quantity | Window aggregate | Empty window | |---|---|---|---| | `counter` | signed delta (a correction is a negative entry) | **Σ qty** over `[from, to)` | omitted — a zero sum bills nothing | | `gauge` | non-negative level sample | **max sample** in `[from, to)` | **carries forward** the latest earlier sample, `entryCount: 0`; omitted only if never sampled | The kind and unit live on the **meter definition** and are frozen after creation — changing a unit mid-period would corrupt every aggregate that spans the change. A new unit is a new meter key. ## The invariants - **Append-only** — nothing in the engine issues `UPDATE` or `DELETE` against `metering_entries`. Corrections compensate; history survives. - **One observation, one row, one event** — `recordUsage` with a seen `(meter, dedupeKey)` and the same qty returns the existing entry and emits nothing; with a different qty it **throws**. That branch returns *before* either `occurred_at` bound below is consulted, so a retry of an already-recorded key never starts failing because the horizon moved in the meantime. - **One aggregation code path** — `usageTotal` (the preview read) and `closePeriod` (the freeze) share the same internal aggregation, so a preview can never disagree with the eventual line. - **Closes are monotonic and non-overlapping** — a new period's `from` must be at or after the latest closed `to`. Gaps are allowed (metering may start mid-life); rewinds are not. - **Nothing lands outside the window around now** — `recordUsage` bounds `occurred_at` on both sides. Behind, it refuses anything before the latest closed `to` (`period_closed`), so closed lines stay reproducible from their entries forever. Ahead, it refuses anything more than five minutes past the operation's own instant (`occurred_at_ahead`) — because the horizon only ever moves forward, an entry post-dated beyond it is aggregated by no close at all and would leave the billing stream with no error anywhere. The forward bound is a clock-skew tolerance, deliberately small. Either way, usage is recorded at observation time (`occurred_at` defaults to now). - **Every mutation emits a fat event; every operation checks a permission.** ## Instants, not days Windows are **half-open `[from, to)` UTC instants** — the [booking engine's](https://substrat.net/engines/booking/index.md) convention, deliberately unlike the [absence engine's](https://substrat.net/engines/absence/index.md) inclusive calendar days. Usage is machine-shaped: a month's window is `[2026-08-01T00:00:00Z, 2026-09-01T00:00:00Z)` and an entry at exactly the boundary belongs to exactly one period — the next one. The instant **at** the horizon is the first legal `occurred_at` after a close. --- # Metering › Operations & permissions Source: https://substrat.net/engines/metering/surface.md ## Operations Registered bindings, each one a permission check plus a call into the in-scope function below. | Operation | Permission | Does | |---|---|---| | `metering/configure-meter` | `metering:configure` | register a meter (kind/unit frozen thereafter); update description/active | | `metering/list-meters` | `metering:read` | list registered meters — a **page**, walked by `key` | | `metering/record` | `metering:record` | append a usage entry — idempotent on `(meter, dedupeKey)` | | `metering/total` | `metering:read` | window aggregate for one meter — the same code path a close freezes | | `metering/list-entries` | `metering:read` | raw entries, filterable by meter / subject / window — a **page**, walked by `(occurredAt, id)` | | `metering/close-period` | `metering:close` | freeze `[from, to)` into lines, advance the horizon, emit the fat event | | `metering/list-periods` | `metering:read` | the close journal — a **page**, walked by `(from, id)` | | `metering/period-lines` | `metering:read` | a closed period's frozen lines — a **page**, walked by `meterKey` | The four reads answer `Page`, not a bare array (#959). The **in-scope** functions beside them stay unpaged: a vertical composing one inside its own transaction is folding it — pricing a period's lines, summing a window — not rendering a table. A caller that needs the whole ledger walks the cursor. ## In-scope functions The composable surface. A vertical calls these **inside its own operation and its own permission check**, in one transaction — this is how you extend the engine without forking it. ```ts configureMeter(ctx, { key, kind, unit, description?, active? }) → Meter listMeters(ctx) → Meter[] recordUsage(ctx, { meter, qty, subject?, occurredAt?, dedupeKey, note? }) → { entry, deduped: boolean } usageTotal(ctx, { meter, from, to }) → { qty, entryCount } | null listEntries(ctx, { meter?, subject?, from?, to? }) → UsageEntry[] closePeriod(ctx, { from, to }) → { period, lines: PeriodLine[] } listPeriods(ctx) → MeteringPeriod[] periodLines(ctx, { periodId }) → PeriodLine[] ``` Notes worth knowing: - **`recordUsage` returns `{ entry, deduped }`** — `deduped: true` means the key had already been recorded (same qty), the existing entry came back, and **no event was emitted**. A retry loop can call it blindly. - **The dedupe key is a contract, not a suggestion.** Derive it from the observation's own identity — a turn id, an aggregation-bucket id (`requests:2026-08-14T13`) — never from a random value, or the idempotency is theatre. - **`occurredAt` defaults to now and is bounded on _both_ sides**, for two different reasons. - *Behind*: never before the close horizon. The period covering that instant is frozen, and a late entry landing inside it would change a line already billed — a closed period stays reproducible from its own entries forever. Refused as `period_closed`. - *Ahead*: never more than **five minutes** past the operation's own `ctx.now()`. That is a clock-skew tolerance, not a feature: the horizon only ever moves forward, so an entry post-dated past every period anyone will close is aggregated into none of them and leaves the billing stream silently. Refused as `occurred_at_ahead`. Either way the answer is the same: record usage at observation time. A caller with a legitimately future observation does not have an observation, it has a plan. Note that the dedupe branch returns *before* either bound is consulted, so retrying an already recorded `dedupeKey` never turns into an error just because the horizon moved. - **Gauges reject negative samples**; counters take signed deltas — a correction is a compensating negative entry. - **`usageTotal` returning `null`** means the meter has nothing to say for the window: a counter with no entries, or a gauge never sampled at all. A gauge with no *in-window* samples returns its carried-forward level with `entryCount: 0`. - **`subject` is optional and opaque** — an `EntityRef` the engine stores and never dereferences, for attribution and filtering ([composing](./composing) covers when to use it versus a side table). ## Permissions `metering:read` · `metering:record` · `metering:configure` · `metering:close` - **`metering:record`** is the ingest key — what a vertical's turn-completion or sampling operation holds. It does not grant closing. - **`metering:close`** freezes billing evidence and advances the horizon — hold it to the same standard as invoicing's export permission, because a close is what turns entries into a bill. - **`metering:configure`** registers meter vocabulary. Kind/unit freezing means this key cannot rewrite history, only extend it. - **`metering:read`** covers meters, entries, totals and closed periods alike — usage data is one audience (the tenant's admins), not many. --- # Metering › Events Source: https://substrat.net/engines/metering/events.md ## Emitted | Event | When | |---|---| | `metering.meter-configured` | a meter registered, or its description/active updated | | `metering.usage-recorded` | a **new** usage entry lands — a deduped replay emits nothing | | `metering.period-closed` | a window is frozen into lines; the billing hand-off | Consumes nothing. ## Payload contracts All payloads are **fat**: a consumer never reads back across the module boundary. - `metering.meter-configured` — `{ key, kind, unit, active }`. - `metering.usage-recorded` — `{ entryId, meterKey, kind, unit, qty, subject, occurredAt, dedupeKey }`. Because a deduped replay emits nothing, **the event stream is itself dedupe-clean**: a consumer summing `qty` per meter sees each observation exactly once (modulo the platform's at-least-once delivery — dedupe on `entryId`). - `metering.period-closed` — `{ periodId, from, to, lines }`, where each line is `{ meterKey, kind, unit, qty, entryCount }`. **Quantities, never prices**: the vertical maps meter keys to rates and feeds [invoicing](https://substrat.net/engines/invoicing/index.md), exactly like the timesheet close hand-off ([composing](./composing)). Dedupe on `periodId`. ## PII Every event is `piiClass: 'none'`, and — unlike the absence engine — writes take **no `DataSubjectId`**: meters count machines and bytes, not people. The optional `subject` ref is an attribution noun (a project, a message), not an identity. A vertical that finds itself metering *people* per-person should stop and think — and if it proceeds, hang its own PII-classified side table off the entry id rather than widening this engine's events. ## Versioning Every event is emitted at `schemaVersion: 1`. Payload fields are **frozen once shipped**: additions are fine, renames/removals/retypes mean a `schemaVersion` bump — the platform-wide rule, restated because billing events are exactly the kind external consumers (a warehouse drain, a Stripe usage connector) quietly grow to depend on. A bump is a **replace, not a dual-emit**: consumer dispatch keys on the event type alone, so two versions in flight would deliver *both* to every consumer of that type — on a billing event, the same usage counted twice. Read [the invoicing engine's `underlag-exported` v2 note](https://substrat.net/engines/invoicing/events.md#versioning) before bumping anything here. --- # Metering › Composing & extending Source: https://substrat.net/engines/metering/composing.md ## Using it as-is Register it and the default bindings work: ```ts import { meteringModule } from '@substrat-run/engine-metering'; host.registerModule(meteringModule); ``` That gets you meter registration, recording, totals, and period close under the engine's own permission keys. Most verticals wrap `recordUsage` instead, because recording happens inside an operation that is already doing something — completing an AI turn, sampling storage — and belongs in that same transaction. ## Recording from your operations The canonical composition — the builder portal recording an AI turn's token usage: ```ts host.defineOperation('builder/complete-turn', async (ctx, input) => { assertAllowed(await ctx.check(MY_PERM.turn)); // … the turn's own work … recordUsage(ctx, { meter: 'ai.tokens.input', qty: String(input.usage.inputTokens), subject: projectRef(input.projectId), // attribution: whose bill line is this dedupeKey: input.turnId, // idempotency: a retried turn never double-bills }); recordUsage(ctx, { meter: 'ai.tokens.output', qty: String(input.usage.outputTokens), subject: projectRef(input.projectId), dedupeKey: input.turnId, // same key, different meter — fine by design }); return …; }); ``` **High-volume sources pre-aggregate before recording.** The ledger is the billable plane, not a telemetry sink: if you are metering requests, roll them up host-side and record one counter entry per bucket, with the bucket id as the dedupe key — ```ts recordUsage(ctx, { meter: 'api.requests', qty: String(hourlyCount), dedupeKey: `requests:2026-08-14T13`, // re-running the rollup is safe occurredAt: '2026-08-14T13:00:00Z', }); ``` — and leave the raw firehose on the platform's Analytics Engine plane, where it belongs. Gauges sample the same way: a daily storage reading is one entry (`dedupeKey: 'storage:2026-08-14'`), and silent days carry the level forward at close. ## Tagging entries: subject, side table — never the meter key Three tools, three jobs: - **`subject`** is the one first-class attribution ref — the noun a bill line splits on (a builder project, a site). Opaque, filterable, in every `usage-recorded` payload. - **A side table keyed by the entry id** is how a vertical attaches anything richer — message id *and* model *and* region. `recordUsage` returns the entry; store `(entry.id, …your columns)` in your own table, same transaction. This is the platform's standard extra-data pattern, and it needs no engine change. - **The meter key is not a tag.** It is the billing dimension — one frozen kind/unit, one line per closed period. Encoding a resource id into it (`ai.tokens.input:msg-123`) explodes the registry, shatters the period lines, and turns the vertical's price list into a junk drawer. If you are tempted, you wanted `subject` or a side table. The dedupe key, meanwhile, often *is* a resource id (a turn id) — that gives you a free lookup handle, but its contract is idempotency: derive it from the observation's identity and expect nothing more of it. ## Pricing and the invoicing hand-off The engine emits quantities; you price them. The wiring mirrors the timesheet close: ```ts // Your consumer on metering.period-closed — parse YOUR OWN Zod view of the payload. const onPeriodClosed: ConsumerHandler = (ctx, event) => { const p = myPeriodClosedPayload.parse(event.payload); const billable = p.lines .map((l) => priceLine(ctx, l)) // meter key → rate: YOUR price list .filter((b) => b !== null); // hand the priced lines to invoicing (or emit your own priced event), dedup on p.periodId }; ``` *When* to close is yours too: declare a schedule in your manifest that calls `closePeriod` monthly, or bind it to your own billing-run operation. The engine deliberately ships no schedule — close cadence is billing policy. ## Growth, archival, and what stays out of the ledger Billable-granularity entries are small — a hyperactive AI-builder scope accumulates well under a megabyte a day — because the high-volume plane is elsewhere by design. If a scope's ledger ever does grow heavy, the **close horizon is the archival seam**: everything behind it is frozen and will never be re-aggregated, so platform tooling can export pre-horizon entries to object storage as an evidence pack and prune the hot rows, keeping the period lines as the summary of record. That is host-side tooling — an engine cannot reach the network — and does not exist yet; it is noted here so nobody solves the growth question by moving the ledger *out* of the scope, which would forfeit the transactionality the dedupe and horizon invariants stand on. ## What extension is *not* The engine registers one frozen constant — no options object, no config field. When you need different behaviour, you compose (your operation calling in-scope functions), store (pricing and vocabulary as scope data), or gate (the entitlement flag). If you find yourself wanting to fork it — a third meter kind, per-subject close lines, a time-weighted gauge — that is a boundary finding worth filing, not patching around: aggregation semantics are exactly the invariants the engine exists to keep identical everywhere, and each of those has a designed additive path. --- # What is a connector? Source: https://substrat.net/connectors/index.md A **connector** is the platform's bridge to a third-party service a *tenant* uses — Scrive for e-signing, Fortnox for accounting, a BankID provider for identity. It is the one place in Substrat allowed to talk to the outside world. Connectors are **host code, never module code.** A vertical cannot call an external API: the [boundary lint](https://substrat.net/concepts/modules.md) bans `fetch` outright, and there is nowhere for a vertical to keep a credential. That prohibition is the whole point — it means "this tenant's Scrive account" lives in one audited place, and a vertical sees only the capability, never the secret. ## Where a connector sits [Decision 18](https://github.com/substrat-run/substrat/blob/main/docs/master-plan.md)'s triage rule sorts every outside dependency into three buckets, and a connector is the third: | Bucket | What | Example | |---|---|---| | **Kernel-owned** | enforcement inputs and contracts | tenancy, permissions, the event spine, **the integrations hub itself** | | **Adapter** | infrastructure the kernel consumes, swappable behind a pure interface | the SQLite/Cloudflare scope host, the `SecretBox` that seals credentials | | **Connector** | a third-party capability tenants use, in the hub | **Scrive, Fortnox, BankID, Swish, Peppol, Kivra, EDI** | The *hub* — the connection store, the runtime, the authority seam — is kernel-owned. Individual connectors are not: they accrete one at a time, as a vertical needs one, and live in `connectors/*` outside the kernel. ## The seam a connector plugs into A connector never invents its own machinery. Four kernel-owned pieces do the load-bearing work, and a connector is small because they exist: 1. **The connection store.** A tenant's authorization for one provider, held by one vertical, keyed **(tenant, vertical, provider)**. Credentials are sealed at rest by a `SecretBox` adapter — the metadata is readable, the secret never is. See [Permissions](https://substrat.net/concepts/permissions.md) for how a connection becomes a subject. The hub is not the only way in: a vertical whose users have no dashboard account starts a provider's consent round itself with [`requestConnectUrl`](https://substrat.net/reference/vertical-host.md#requestconnecturl-request), and the connection still lands in this store, stamped with the principal whose permission check authorized it. 2. **The connector runtime.** `registerConnector(id, eventType, handler)` binds a handler to an event. When a module emits that event, the runtime hands the handler an opened credential and a **`fetch` bound to the connection** — so a timeout, egress policy, and per-connection health recording come for free, and module code still cannot reach any of it. 3. **At-least-once delivery with retry.** The handler rides the same journal, backoff and dead-letter as every executor. A provider being briefly down is ordinary; the runtime retries with backoff and surfaces a dead letter rather than dropping the effect. 4. **The inbound authority seam.** A provider's callback is not a person, so it cannot hold a `PrincipalId`. Instead **a connection is a subject**: `getConnectorScope(connectionId, scopeId)` opens a scope stub whose authority is the connection's own grants, narrowed by construction to that tenant and vertical. Events it causes are stamped `{ connection }` on the spine — the audit trail says "Scrive did this" without naming a human who did not. **Diagram — the connector round trip.** A delivery leaves the scope, crosses the platform boundary, and comes back in through a different door. 1. **Scope DO** (durable object) — The operation committed; the event is in the outbox. no fetch() here — the lint forbids it. 2. It **delegates the delivery** up to the **Connector runtime** (worker) — Opens the sealed credential for this tenant. fetch bound to the connection · retry. 3. The runtime **sends the document** to **The provider** (outside the platform) — Scrive, Fortnox, a bank. It never sees a principal. 4. Later, **the provider calls back**; the worker reopens the scope with `getConnectorScope()` and writes the result back. A provider’s callback is not a person, so it cannot hold a principal. The connection itself is the subject: getConnectorScope opens a stub whose authority is that connection’s own grants, narrowed by construction to one tenant and one vertical. ## What a connector must answer for itself The seam supplies the machinery; four answers are the connector's own, and only the last is optional. 1. **What it does at the provider** — the handler, or the sweep for a poll-only connector. 2. **Whether this credential is any good** — a **probe**, and this one is not negotiable. Write it as a pair: one that checks a candidate secret at connect time, before anything is stored, and one that checks the credential a live connection already holds. Reach for the cheapest authenticated read the provider offers that *names the account* — Fortnox's `/companyinformation`, Planima's `/organizations` — because the question an operator actually has is not "is this token valid" but "does it see the customer I meant". A valid token from the wrong login is the failure a syntax check cannot catch. 3. **What this connection has been doing** — activity, projected from the connector's own ledger into a declared shape, so redaction is structural. 4. **Which credential is loaded** — identifiers whole, secrets masked by the connector's own rule. The probe is mandatory because of what its absence looks like from the outside. Verify answers `501` for a provider with no probe registered — correct for a provider the platform does not operate, and actively misleading for one it does: the tenant pressing **Test connection** reads "Couldn't reach the provider", and blames the provider for our missing wiring. A connector that skips it also skips its own connect-time gate, so a mistyped credential is stored and looks healthy until the first real dispatch fails. If a provider genuinely exposes nothing to probe with, the connector still carries a probe that *says* so, rather than leaving the route to 501. A stated "this provider exposes no verification read" is something a console can render and a person can act on. ## What a connector is *not* - **Not an [engine](https://substrat.net/engines/index.md).** An engine owns invariants, operations, events and domain state inside a scope. A connector owns none of those — it *consumes* an event and *effects* something outside. It has no tables, no permissions of its own, no domain model. - **Not swappable infrastructure.** The KMS behind `SecretBox` is an *adapter* (bucket 2). A connector is a capability a tenant deliberately connects, not plumbing the kernel picks. - **Not a way around the module rules.** A connector cannot read a vertical's tables. If a connector needs a vertical's data, the vertical puts it in the event payload — the connector works from that and nothing else. ## Available connectors Connectors accrete per vertical need, so this list is short by design and grows one entry at a time. **Status** is honest: a connector can be documented and half-built, because the seam it needs may not exist yet. | Connector | Category | Status | Provider | |---|---|---|---| | [Scrive](https://substrat.net/connectors/scrive.md) | E-signing & identity | **Published** ([npm](https://www.npmjs.com/package/@substrat-run/connector-scrive), `0.x`) — both halves built and running in production; two caveats: the vertical schedules the poll, and BankID is off on the testbed | Scrive eSign (Swedish BankID) | | [Fortnox](https://substrat.net/connectors/fortnox.md) | Accounting | **Live-verified** (`0.x`) — poll-only, reads bookkeeping as SIE4; client-credentials auth, so no refresh token to rotate. The live run corrected the export charset (PC8/CP437, not latin1) | Fortnox (Swedish accounting) | | [Planima](https://substrat.net/connectors/planima.md) | Facility maintenance | **Built, not yet live-verified** (`0.x`) — poll-only and read-only, reads a maintenance plan and its costed actions; one static API token, so nothing to rotate. Every claim still rests on the published OpenAPI document and a mock that shares its reading | Planima (Swedish maintenance planning) | Categories, as they will fill in (from the master plan's build list): - **E-signing & identity** — Scrive, BankID, Kivra - **Accounting** — Fortnox, Visma - **Facility maintenance** — Planima - **Payments** — Swish - **E-invoicing & EDI** — Peppol, Ahlsell / Rexel / Sonepar ## How these pages are organized Every connector documents itself the same way — a different shape from an engine's five pages, because a connector is a different thing. One page, these sections: | Section | Answers | |---|---| | **At a glance** | provider, category, status, the npm package | | **What it consumes** | the event that triggers it, and what the payload must carry | | **The credential** | what the connection stores, and what must *never* be stored | | **The flow** | what it does at the provider, step by step | | **What's missing** | the seams it still needs — stated plainly, because an incomplete connector that pretends otherwise is worse than none | If a connector cannot fill "What's missing" with an empty list, it is not done, and the page says so. --- # Scrive (e-signing) Source: https://substrat.net/connectors/scrive.md Turns a signature request from the [protocol engine](https://substrat.net/engines/protocol/index.md) into a real signing flow at **Scrive**, authenticated with Swedish **BankID**. > **Warning — Published, with two honest caveats a consumer must own** > > Both halves work and it now ships as a public package: a request becomes a started Scrive document > (**outbound**, verified against `api-testbed.scrive.com`), and a completed signature is recorded > back into the scope (**inbound**, via `reconcileScriveDispatch` on the [authority seam](https://substrat.net/connectors/index.md#the-seam-a-connector-plugs-into)). > It is a `0.x` release, which already signals an unstable surface, and two caveats travel with it — > neither in the connector code: **the consuming vertical must schedule the poll on a timer** > (`sweepScriveReconciliations` via `startPlatformSweeper` on node, or `definePlatformSweeperDO` — > a self-re-arming Durable Object alarm in `@substrat-run/adapter-cloudflare` — on Workers; see the > [scheduler](https://github.com/substrat-run/substrat/blob/main/docs/architecture/scheduler.md)), > and **BankID-to-sign is disabled on the testbed account**, so the real signing round-trip is > unverified. See [What's missing](#what-s-missing). ## At a glance | | | |---|---| | **Provider** | Scrive eSign, `se_bankid` authentication-to-sign | | **Category** | E-signing & identity | | **Status** | Published — both halves built (outbound + return path + poll driver); two caveats: the vertical must schedule the poll, and BankID is off on the testbed | | **Package** | [`@substrat-run/connector-scrive`](https://www.npmjs.com/package/@substrat-run/connector-scrive) — public, `0.x`; npm carries the current version | | **Consumes** | `protocol.signatures-requested` | | **Registered with** | `registerConnector('scrive', 'protocol.signatures-requested', …)` | ## What it consumes The [protocol engine](https://substrat.net/engines/protocol/model.md) emits `protocol.signatures-requested` when a vertical freezes a document and sends it for signature. This connector only answers when the event's `method` is `scrive` — a vertical asking for BankID through another provider emits the same event, and this must not answer for it. The payload it reads is **fat by design** — a connector cannot read the vertical's tables, so everything it needs travels on the event: the template key and version, the content hash, the bound document hash, and the parties (each with a label, a `principal` / `external` kind, and a `primary` / `counter` signature kind). ## The credential The connection stores Scrive's **OAuth1 personal access credentials** — four parts: ```json { "clientId": "…", "clientSecret": "…", "tokenId": "…", "tokenSecret": "…" } ``` sealed at rest by the host's `SecretBox`. They combine into a PLAINTEXT signature header on every call (`oauth_signature="&"`); there is no token exchange. > **Tip — Verified against reality** > > The connector was first written for OAuth2 bearer tokens, from the docs. A live call to the > testbed rejected that immediately — Scrive's UI labels "Client credentials" and "Token > credentials" are two *halves* of one credential, not two schemes. This is the > [connector seam's](https://substrat.net/connectors/index.md) "green means ready to check, never verified" caveat cashing > out, and why the connector carries an opt-in test that runs against `api-testbed.scrive.com` > when credentials are present. **A personnummer is never stored.** When a party signs with BankID, their Swedish personal number is passed *through* to Scrive on the signing request and kept nowhere: it is `direct` PII, and the protocol engine deliberately records an opaque `DataSubjectId` as the signatory instead. The provider needs the number; our tables must not have it. ## The flow Given a `scrive` signature request, the connector: 1. **Renders an attestation sheet** — a one-page PDF naming the template, the parties, and the content hash the signature refers to. This is **not the contract**: rendering the real avtal belongs to the vertical that owns its content (see [What's missing](#what-s-missing)). The PDF writer is dependency-free and web-standard, and its text encoder *throws* on any character it cannot represent rather than substitute silently — PDF text is WinAnsi, where an unmapped character otherwise turns an em-dash into a euro sign on a document someone is about to sign. 2. **Creates the document** — `POST /api/v2/documents/new`. 3. **Attaches the file** — `POST …/setfile`. Separate from creation in Scrive's own API, which is what makes the no-file creation step possible. 4. **Sets the parties** — `POST …/update`. The API account holder is sent as a **non-signing author** (`is_author: true`, `is_signatory: false` → `signatory_role: "viewer"`), and **every party the vertical names signs as itself**. This is deliberate: Scrive binds the author party to the account holder and silently overwrites the name and email sent on it, so while the issuing party was the author, the account owner signed for whoever the vertical had actually named — and the return path, which refuses to attribute a signature when the provider's party name disagrees with the dispatched label, could never record it. The dispatch state records that the sender was sent (`senderParty`), because the reconcile matches the Nth signatory to provider party N+1. Each party's authentication method follows its `authLevel`: `strong` → `se_bankid`, `basic` → the connector's `defaultAuthMethod` (`standard` unless configured). And because every party is invited, **a party with no email or mobile is refused before egress**, and the error names the party by its label. Left to Scrive, the same mistake comes back as a positional `participant #2` error — an index into a party list the vertical never saw. 5. **Sets a capability callback URL** — an unguessable secret in the path, because Scrive's callbacks carry **no signature to verify**, so a callback can only ever be a *hint* to re-read the document, never a trusted fact. 6. **Starts it** — `POST …/start`. The document is now `pending` and the parties are invited. 7. **Records the dispatch** in a directory-side ledger (`putConnectorState`, keyed by the connection), carrying the document id, the frozen content hash, and each party's request id and signatory ref. Two jobs: a redelivery finds this row and **skips** instead of sending a second document, and the poll driver reads it to map a signed party back to the request it resolves. Directory-side because a connector runs *inside* the scope's dispatch and re-entering the scope actor deadlocks. Retry is tuned for a contract, not a directory write: **8 attempts**, 5-second base backoff, up to a 15-minute ceiling. Giving up on a signature after the executor default of five tries would be giving up on a contract. ## Reaching the signature back Once a party signs, Scrive changes the document status, and the connector records that signature onto the protocol instance in the scope. Two ways to learn of the change; this connector is built for the first, and the second is a later optimization on the *same* write-back: - **Polling** — `GET /api/v2/documents/{id}/get` returns the full document and its status. This needs no ingress at all, which is why webhook transport ([#96](https://github.com/substrat-run/substrat/issues/96)) is *not* on the critical path. - **Webhooks** — Scrive can POST on status change, but unauthenticated, so the callback URL is a capability and the body is never trusted. A webhook is an optimization over polling, not a replacement for it — even with one, the handler re-reads `…/get` and runs the same reconcile. The write-back goes through the [inbound authority seam](https://substrat.net/connectors/index.md#the-seam-a-connector-plugs-into): `reconcileScriveDispatch` opens a scope stub with `getConnectorScope` — the connection acting as itself — and records the signature by invoking `protocol/record-signature`, gated on the connection's own `protocol:record-signature` grant (visible in the permission diff). It runs as a **top-level operation, outside any dispatch**, so re-entering the scope is safe; it re-checks the provider-reported content hash against the frozen one and fails closed on a mismatch; and it is idempotent across polls. `sweepScriveReconciliations` is the poll driver over it — it enumerates the dispatch ledger (`listConnectorState`) and reconciles every outstanding instance. The **timer** that calls the sweep now exists for both runtimes: `startPlatformSweeper` (node, a self-rescheduling interval — live in the Meridian demo server) and `definePlatformSweeperDO` (`@substrat-run/adapter-cloudflare`, a self-re-arming Durable Object alarm — chosen over a cron because a vertical pushed into a Workers-for-Platforms dispatch namespace gets no `triggers.crons`). Both drive the same `runPlatformSweep` (the kernel's [scheduler](https://github.com/substrat-run/substrat/blob/main/docs/architecture/scheduler.md) unit of work), and the control-plane worker is deliberately not the home, because its scope DO is module-less. The call site belongs in the vertical's own runtime — what remains is a *deployed* vertical wiring one up with a live connection. ## What's missing Most of the gaps found by building it have since closed. What is done, and what is left: 1. ~~**Recording the provider's document id / dispatch idempotency.**~~ **Done.** A redelivery once created a *second* Scrive document — duplicate legal paperwork to real signatories, the sharp correctness problem. The connector now records each dispatch in a directory-side ledger and skips if a prior one is found. 2. ~~**Recording a signature.**~~ **Done** via the [authority seam (#97)](https://github.com/substrat-run/substrat/issues/97): `reconcileScriveDispatch` records it as the connection, through `getConnectorScope`. 3. ~~**Connector state has no home.**~~ **Done** — the dispatch ledger above is that home (`putConnectorState` / `listConnectorState`). 4. **No document store.** There is still no place for rendered document *bytes*, which is why this sends an attestation sheet rather than the avtal, and why the real contract's rendering waits on the vertical plus a store. 5. ~~**Nothing schedules the poll.**~~ **Done — both triggers ship.** `startPlatformSweeper` drives the sweep on node (wired in the Meridian demo server), and `definePlatformSweeperDO` (`@substrat-run/adapter-cloudflare`) drives it on Workers: a singleton Durable Object whose alarm runs one `runPlatformSweep` pass and re-arms only after the pass settles — non-overlap by construction, and it works inside a Workers-for-Platforms dispatch namespace, where crons do not ([#96](https://github.com/substrat-run/substrat/issues/96), the poll path). For a hosted CP-less vertical the platform runs the whole pass ([#574](https://github.com/substrat-run/substrat/issues/574)): the control plane's scheduled sweep enumerates ITS connection directory (phase 1), the webhook ingress terminates on the control plane (phase 2), and outbound dispatch rides platform-requests (phase 3) — the vertical registers the connector as usual, its host routes each delivery as a `connector:scrive` intent, and the platform executes the same handler with the credential and egress the vertical must never hold. 6. **BankID-to-sign is disabled on the testbed account**, so `start` returns 409 for `se_bankid` and the real BankID signing round-trip (and Scrive's live `get` party shape and order) cannot be verified yet. The live test uses `standard` auth until it is enabled. Because the return path landed, the connector no longer takes a required write-back callback: the callback URL is now **optional** (polling is a complete strategy on its own), and where it is set, a callback is only ever a hint to re-read — never a trusted fact. ## Testing without an account `ScriveMock` implements the documented endpoints in memory, so the whole outbound lifecycle runs without a Scrive account. What it proves is that *our shape* works — credential resolution, egress, the document lifecycle, retry. It **cannot** prove our reading of Scrive's API is correct, because it *is* that reading: same author, same misunderstandings, on both sides of the call. A green suite means "ready to check against `api-testbed.scrive.com`", never "verified." --- # Fortnox (accounting) Source: https://substrat.net/connectors/fortnox.md Reads a company's bookkeeping out of **Fortnox** as a SIE4 export, sums it per account, cost centre and month, and lands the result into a scope through the consuming vertical's own operation. > **Tip — Verified against a live company — and it found two bugs** > > Every claim below has now been checked against `api.fortnox.se`, and two of them were wrong. > **The SIE4 export is PC8/CP437, not ISO-8859-1** — decoding it as latin1 turned every `ö` into > `”` in account names, silently, with no error anywhere. And `companyinformation` returns explicit > `null` for unset text fields, which the response schema rejected outright. Both are fixed; both > were invisible to a mock, because the mock encoded latin1 too and so agreed with the reader while > both disagreed with Fortnox. > > The credential model is now proven rather than argued: `grant_type=client_credentials` with a > `TenantId` header really does mint, with no refresh token in the response. ## At a glance | | | |---|---| | **Provider** | Fortnox (Swedish accounting), REST API v3 + SIE4 export | | **Category** | Accounting | | **Status** | Live-verified against a Fortnox test company — poll-only, no outbound writes | | **Package** | `@substrat-run/connector-fortnox` — `0.x` | | **Consumes** | *nothing* — this connector answers no event | | **Registered with** | *nothing* — it is a `ConnectorSweeper`, bound into the platform sweeper | ## What it consumes **Nothing, and that is the design.** Every other connector so far answers an event: a vertical does something, the engine emits, the connector effects it outside. This one runs the other way. Nobody inside a scope initiates it — a vertical does not *ask* for last month's bookkeeping the way it asks for a signature. The books change at Fortnox, and the platform finds out by looking. So there is no `registerConnector` call and no dispatch handler. `sweepFortnoxLedger` is a [`ConnectorSweeper`](https://substrat.net/connectors/index.md#the-seam-a-connector-plugs-into), the deployment binds it into the platform sweeper beside Scrive's, and that is the entire trigger surface. What replaces the event is a **binding**: a one-time, explicit declaration of which scope this connection syncs into, which operation the ledger lands through, and which permission that operation checks. ```ts await bindFortnoxScope(host, { connectionId, tenantId, scopeId, vertical, operation: 'ledger/record-period', // yours permission: 'ledger:record', // what it checks }); ``` `bindFortnoxScope` **refuses when the connection does not hold that permission on that scope**. A sweep can therefore never be configured into a state where it fetches a year of bookkeeping and then cannot write it down — the failure lands in the operator's hands, naming what to grant, instead of in a background timer nobody is watching. ## The credential Fortnox's usual flow is OAuth2 authorization-code: a person signs in, you store an access token and a **refresh token**, and you refresh forever. This connector deliberately does not do that. Fortnox refresh tokens are **single-use and rotating** — each refresh mints a new one and invalidates the old. Two concurrent refreshes therefore race: one wins, the other receives `invalid_grant` and writes an already-dead token, and the connection is bricked until someone reconnects it by hand. Every integration built that way carries the same three mitigations (coalescing concurrent refreshes per company, optimistic locking on the token write, a distinct "reconnect Fortnox" error). **This connector holds no refresh token, so it cannot reach that state.** Fortnox supports `grant_type=client_credentials` against a *service* consent, and a token minted that way comes from three static values: | Field | What | |---|---| | `clientId` | From the Fortnox Developer Portal — the integration's identity | | `clientSecret` | From the Developer Portal — sealed at rest by the host's `SecretBox` | | `tenantId` | The **company's numeric `DatabaseNumber`**, sent as the `TenantId` header | ``` POST https://apps.fortnox.se/oauth-v1/token Authorization: Basic base64(clientId:clientSecret) TenantId: 123456 grant_type=client_credentials → { access_token, token_type, expires_in: 3600, scope } // note: no refresh_token ``` An access token lives an hour and is minted on demand, cached for the life of one sweep pass. One client pair serves a whole fleet; `tenantId` is the only part that differs per company. **What is never stored:** nothing sensitive beyond the client secret. This connector reads bookkeeping totals — accounts, cost centres, months, sums. It never reads a customer register, a supplier invoice, or an employee record, and it writes nothing back to Fortnox at all. ### The one-time consent Client credentials are not consent-free. Each company's sysadmin authorizes once, in a browser, and the consent must be created with **`account_type=service`** — that is what makes it mintable by client credentials afterwards. **The dashboard runs that round for you.** The Fortnox card in the dashboard's Integrations view offers two ways through it, and both end in a sealed connection on the chosen app with nothing typed: - **Connect** — a dashboard admin clicks it, approves at Fortnox, and comes back. The link behind that button lives ten minutes, because this browser is about to spend it. - **Copy connect link** — the same URL, handed to whoever actually administers Fortnox. It needs **no dashboard login**: the person approving is usually finance or an external accounting firm, and making them a dashboard member first would be the wrong door. Seven days by default, single-use, and revocable from the same card right up until it is spent. Either way the closing page names **which company** was attached — name, organisation number, database number, and how many financial years the new credential can read. That naming is the point: a consent granted while signed into the wrong company-switcher entry looks identical to a correct one up to that moment. The client pair is a **platform** secret here, one integration for the whole fleet, so a customer has no client id or client secret to paste even if they wanted to — and the third value, `DatabaseNumber`, does not exist until a consent has happened. The dialog's *Enter credentials manually* toggle is the fallback for an already-assembled triple, not the way in. **Hosting your own callback?** Do not re-implement the round. `completeFortnoxConsent` is the exported sequence, and it is the same code the dashboard and the local `pnpm fortnox:connect` script both run: ```ts import { completeFortnoxConsent } from '@substrat-run/connector-fortnox'; // Judge `state` FIRST — see below. Only then: const { secret, company, financialYears } = await completeFortnoxConsent({ clientId, clientSecret, code, redirectUri, fetch, }); // secret: the sealed-ready { clientId, clientSecret, tenantId } triple // company: whose consent this was — show CompanyName to the person who clicked approve ``` Two things stay **yours**, because the helper takes a bare `code` and cannot know them. **`state` is judged before anything else, including a reported error.** The helper validates the authorization code and nothing more, so a callback that calls it on whatever arrives will happily complete a round it never started — and attach *the attacker's* Fortnox company to the victim's scope. Bind the round when you build the consent URL and verify it on the way back before you read `code` at all. That is what the dashboard does (its `state` is a signed claim naming the connect link's row, which is also what makes the link single-use and revocable), and what `scripts/connect.mts` does locally. Then, still before the helper, two callbacks end the round rather than start an exchange: one carrying `?error=…` (Fortnox declined — a denial usually carries no `code` at all), and one carrying no `code` for any other reason. `completeFortnoxConsent` is for the remaining case, where a real authorization code came back. **`redirectUri` must be the registered one, character for character, and the same string twice** — once in the consent URL, once in the exchange. Fortnox validates it only *after* login, so an unregistered value gets you a login screen and then an `invalid_grant` at the exchange, which reads exactly like a spent code. #### What that button actually does `fortnoxConsentUrl` builds the URL the admin is sent to: ```ts fortnoxConsentUrl({ clientId, redirectUri, state, scopes: ['bookkeeping', 'companyinformation'] }); // always sets account_type=service and access_type=offline ``` When the redirect comes back, three steps follow, and the third is the one that matters: 1. **Exchange the code**, once. `grant_type=authorization_code` is used here and nowhere else in this connector, and the tokens it yields are discarded after step 2. 2. **Read `GET /3/companyinformation`** for `DatabaseNumber`. That number becomes the connection's `tenantId`, and it is shown nowhere in the Developer Portal — which is why the credential cannot be assembled by hand at all. 3. **Mint again with `client_credentials`** from the assembled triple. This is the connector's whole premise, so it is proven at connect time, in the operator's hands, instead of first discovered by a background sweep nobody is watching. > **Danger — Scopes cannot be widened without a new consent round** > > Ask for what the integration will need, not what it needs today. Fixing this later means going > back to every customer. Note also that **every Fortnox scope grants read *and* write** — there is > no read-only variant — so an extra scope widens what the credential *could* do, even for a > connector like this one that has no write path at all. ### What the customer needs before any of this works Two prerequisites sit outside your code, and neither announces itself clearly when missing. **The customer needs a Fortnox integration licence.** Connecting *any* third-party integration requires it, it is bought by the **Fortnox customer** on their own subscription — not by you as the integrator — and it is added once and then covers unlimited integrations. A customer who already runs some other integration pays nothing more to add yours. One who does not will be stopped at the consent screen by a purchase prompt, so it belongs in your onboarding text rather than as a surprise mid-flow. **Your scopes must be ticked on the integration in the Developer Portal.** The authorize endpoint validates `scope` against what that integration registered, *before* login, and answers a bare `invalid_scope` — naming neither which scope was refused nor that registration is the fix. With several scopes in one request you cannot tell which one it objected to. There is a quick way to find out, and it needs no browser: request the authorize URL one scope at a time and read the `Location` header. A refused scope redirects straight back with `error=invalid_scope`; an accepted one returns the login page. ```bash curl -s -D - -o /dev/null \ "https://apps.fortnox.se/oauth-v1/auth?client_id=$CLIENT_ID\ &redirect_uri=http%3A%2F%2Flocalhost%3A8899%2Fcallback\ &scope=companyinformation&state=probe&response_type=code&account_type=service" \ | grep -i '^location:' ``` `redirect_uri`, by contrast, is validated only **after** login, so the same trick cannot tell you whether a callback URL is registered — an unregistered one still returns the login page. ## The flow Per bound scope, each sweep pass: 1. **Mint** an access token (cached for the pass — one mint, not one per request). 2. **`GET /3/financialyears`** and pick the year that **overlaps** the period. 3. **`GET /3/sie/4?financialyear=`** — the whole year's bookkeeping in one request, instead of walking per-voucher REST endpoints. 4. **Hash** the payload. Unchanged since last sync ⇒ stop here, land nothing. 5. **Parse** the SIE4 (`#VER` / `#TRANS`, plus `#KONTO`, `#DIM`, `#OBJEKT` for labels). 6. **Sum** per (account, cost centre, month) with exact decimal arithmetic. 7. **Land** it in pages of 500 through the binding's operation, as the connection itself. The cursor is written **only after every page lands**, so a mid-way failure leaves it at the previous hash and the next sweep retries the whole year rather than resuming into a half-written ledger. ### Four things the format will bite you with These are not incidental; each one produces wrong data rather than an error. **SIE4 is PC8 — code page 437 — and getting this wrong is invisible.** The file declares it (`#FORMAT PC8`), but the HTTP response does not: there is no charset in the content type. CP437 is a pre-Unicode MS-DOS encoding, so `0x94` is `ö`, `0x84` is `ä`, `0x86` is `å`. Two traps follow. *Decoding as UTF-8* throws on the first Swedish letter, which is the good case — you find out. *Decoding as latin1* is the bad case: **every byte 0x00–0xFF is a valid latin1 code point**, so nothing fails, and you get a ledger full of plausible-looking account names with the wrong letters in them. This connector shipped that way, and every test in the package passed, because the mock encoded latin1 too — the reader and the fixture agreed with each other while both disagreed with Fortnox. Only a live call could see it. Two consequences worth copying into any SIE reader you write: - **`TextDecoder` cannot do this.** CP437 is not in the WHATWG Encoding registry, so `new TextDecoder('cp437')` throws `RangeError` in Node, browsers and Workers alike. A mapping table for bytes 0x80–0xFF is the only portable option. Note also that `new TextDecoder('iso-8859-1')` silently gives you **windows-1252**, which differs from latin1 in exactly the 0x80–0x9F range where CP437 keeps its Swedish letters. - **"No replacement character" is not a charset test.** Asserting that the decoded text contains no `�` cannot fail for a latin1 decode, so it proves nothing. Assert instead that no C1 control (`U+0080`–`U+009F`) appears *and* that the Swedish letters do — a chart of accounts has them. `decodeSie` is the one place that knows, it reads `#FORMAT` and **refuses** a charset it does not implement rather than guessing, and `FortnoxMock` now encodes CP437 the way the provider really does. **Pick the year by overlap, never containment.** A newly-acquired company's first financial year can start mid-month (24 April, say). Searching for "the year containing 1 January" finds nothing, and the sync reports empty books for a company with a full year of bookkeeping. **Amounts are debit-positive, and this connector does not normalize them.** A cost account carries a positive amount for a cost; a revenue account (a credit) carries a negative one. That is correct double-entry — inventing a sign here would put business meaning in a format reader. Your mapping applies the sign. **`#IB` / `#UB` / `#RES` are skipped.** They are the opening and closing balances the vouchers already sum to. A reader that adds them to the `#TRANS` rows reports every figure twice. ### Cost centres come free SIE reserves **dimension 1** for *kostnadsställe*, and Fortnox follows the standard. So a per-property or per-unit breakdown needs no extra API call and no server-side filtering — it is read straight out of each transaction's object list, and `#OBJEKT` rows carry the labels. ## Where the data lands, and why the connector does not decide The connector hands you a `FortnoxLedgerPage` — neutral accounting fact — and stops. What a business *means* by those numbers (which account is `lokal_grundhyra`, which sign normalizes it, which group it rolls into) is **vocabulary**, and vocabulary is [the vertical’s layer](https://substrat.net/verticals/index.md). A connector that mapped account numbers to row keys would be a vertical wearing a connector's clothes, and the second customer with a different chart of accounts would have to fork it. This is also why the connector declares **no standing grants**. Every other connector names its return-path permissions so the dashboard's door can be checked against them; this one's permission belongs to the consuming vertical and is unknown at build time. The check moves from build time to bind time rather than disappearing — see `bindFortnoxScope` above. ## What's missing 1. ~~A live verification run.~~ **Done**, against a Fortnox test company. It found the charset bug above and a schema that rejected `null` in optional text fields, and it confirmed the two claims that mattered most: `client_credentials` mints with a `TenantId` header and no refresh token, and `DatabaseNumber` is what the header wants. `test/live.test.ts` runs whenever `secrets/connectors.env` (shared by every connector) holds `FORTNOX_CLIENT_ID`, `FORTNOX_CLIENT_SECRET` and `FORTNOX_TENANT_ID`, and skips otherwise, so CI without secrets stays green. Assembling that credential is not copy-paste — `DatabaseNumber` is shown nowhere in the portal — so `pnpm fortnox:connect` runs the one-time service consent locally, reads it, verifies `client_credentials` against it, and prints the line to add. The suite reads only: the data calls are GETs and the one POST is the token mint, which creates no Fortnox record. This connector has no write path to Fortnox, so it is safe against a production company too — and that property is worth protecting, which is why seeding a sandbox with test data lives in a **separate** script, `pnpm fortnox:seed`, that refuses to write to any company whose organisation number is not Fortnox's sandbox marker `555555-5555`. ::: warning An empty sandbox is worse than no sandbox A fresh test environment has no financial year and no vouchers. Three of the five live tests then fail on absent data rather than on a defect — and, far worse, the two assertions that matter most never execute: the charset check and the double-entry check both need a real export with real Swedish account names in it. An empty sandbox lets a broken decoder pass. Seed first, then judge a red. ::: 2. **A consumer.** No vertical in this repo binds it yet, so the landing operation exists only in the test suite. The seam is proven; the first real mapping is not written. 3. **A deployment that schedules the sweep.** The connector provides the driver and cannot hold a timer. Without `startPlatformSweeper` or `definePlatformSweeperDO` wired, a binding is inert. 4. **Supplier invoices and their PDFs.** The `supplierinvoice`, `archive` and `connectfile` scopes exist and would let this read supplier invoices and fetch their files. Nothing here does — and because scopes cannot be widened after consent, a deployment that expects to want them should ask for them in the *first* consent round. --- # Planima (facility maintenance) Source: https://substrat.net/connectors/planima.md Reads a **maintenance plan** out of [Planima](https://planima.se/) — facilities, buildings, components, and the costed actions a property owner intends to carry out in a given year — and lands it into a scope through the consuming vertical's own operation. > **Warning — Not yet verified against a live account** > > Everything below is written from [Planima's published OpenAPI > document](https://developer.planima.se/) and is checked by a mock that encodes the same reading. > That means mock and client can agree with each other while both disagree with Planima. The > `connector-fortnox` page above is what that looks like when it goes wrong — two premises held for > months because the mock shared them. > > `test/live.test.ts` is written and skips until `PLANIMA_TOKEN` is present. Until it has run, treat > the credential shape, the paging envelope and the nullability of every field as *claims*, not > facts. ## At a glance | | | |---|---| | **Provider** | Planima (Swedish planned facility maintenance), REST API v1 | | **Category** | Facility maintenance | | **Status** | **Built, not yet live-verified** — poll-only, read-only, no outbound writes | | **Package** | `@substrat-run/connector-planima` — `0.x` | | **Consumes** | *nothing* — this connector answers no event | | **Registered with** | *nothing* — `sweepPlanimaPlan` is bound into the platform sweeper | ## What it consumes **Nothing, and that is the design** — the same shape as [Fortnox](https://substrat.net/connectors/fortnox.md), and for the same reason. Nobody inside a scope initiates this. A vertical does not *ask* for next year's maintenance plan the way it asks for a signature; the plan changes in Planima — a surveyor walks a roof and moves an action from 2031 to 2027 — and the platform finds out by looking. So there is no `registerConnector` call and no dispatch handler. `sweepPlanimaPlan` is a sweeper, the deployment binds it into the platform sweeper beside Fortnox's, and that is the entire trigger surface. What replaces the event is a **binding**: a one-time, explicit declaration of which scope this connection syncs into, made by `bindPlanimaScope`. ```ts await bindPlanimaScope(host, { connectionId, tenantId, scopeId, vertical: 'maintenance', operation: 'maintenance/record-plan', // the CONSUMER's operation permission: 'plan:record', // which that operation checks organizationId: 1, // or null for every one the token sees horizonYears: 10, // rolling: this year .. this year + 10 }); ``` `bindPlanimaScope` **refuses a binding whose grant is missing**, naming the permission to grant. That is deliberate and it is the whole reason binding is a function rather than a config object: the alternative is to write the binding, then discover at sweep time that the connection cannot invoke the operation — in a background timer nobody is watching, with a whole maintenance plan already fetched against a rate limit that only allows ten requests every ten seconds. ## The credential **One static API token, and nothing else.** A person creates it in Planima under *account settings → API*. | Stored | Never stored | |---|---| | `token` — the API token, sealed by the host's `SecretBox` | a Planima password; there is no OAuth flow, so no refresh token and no rotation hazard | Two things about this token are worth stating plainly, because they are the entire security model: 1. **It carries the access level of the user who minted it.** There are no scopes. A read-only user's token cannot write; an admin's token can do everything that admin can. So the narrowing that matters is operational — **mint it as a read-only user**. This connector never writes, so nothing is lost by doing so, and a leaked read-only token costs a plan someone could already see. 2. **It does not expire.** That removes the rotation hazard Fortnox's refresh tokens carry, and replaces it with a different one: revocation is out-of-band, so the first the platform hears of a revoked token is a `401`. `PlanimaApiError.refused` is what carries that distinction — a `401` marks the connection unhealthy, while a timeout or a `429` does not, because neither is a fact about the credential. The connect-time probe reads `/organizations` rather than merely checking the token parses. That is the cheapest authenticated read Planima offers *and* it answers the question an operator actually has: not "is this token valid" but "does it see the account I meant". A token from the wrong Planima login is perfectly valid and syncs somebody else's buildings. ## The flow One sweep pass, per bound scope: 1. `GET /facilities` (optionally `?organization_id=`) — every facility the token can see. 2. For each facility, in order: `GET /facilities/{id}/buildings`, `GET /facilities/{id}/components`, `GET /actions?facility_id={id}&start_year=…&end_year=…`. 3. Assemble, sort by id, and hash. **An unchanged hash lands nothing.** 4. Otherwise page the plan and `invoke` the consumer's operation once per page, as the connection itself. 5. Write the cursor — but only **after** every page landed, so a failure mid-way leaves the previous hash in place and the next sweep retries the whole plan rather than resuming into a half-written one. ### Four things the API will bite you with **The `Authorization` header is the bare token.** No `Bearer` prefix. Planima's own example is `-H "Authorization: NotARealToken+tm6rdPsx23u+4/HiguLIFQw="`, and a prefix fails identically to a bad token — a `401` that reads like a credential problem and is not. The mock refuses a prefix for exactly this reason. **`page[limit]` is capped at 50, silently.** Asking for 500 returns 50 with no error and no hint that the answer was cut, so a client that trusts its own limit and stops after one page syncs a truncated plan and reports success. The walk asks for 50 and pages until a short page arrives. **Ten requests per ten seconds, per token.** A sweep costs *at least* `1 + 3 × facilities` requests — twenty facilities is sixty-one requests and about a minute of mostly waiting — and any collection over the 50-item page cap adds more. The client throttles itself with a sliding window shared across every binding in a pass (Planima meters per token, not per client) and, when Planima answers `429`, obeys the `Retry-After` it sends rather than backing off on a schedule of its own invention. That is why the sweep interval is a real decision rather than a free knob. **There is no currency anywhere.** Planima sends `unit_price: 1200` and nothing else — no currency on the action, the facility, the organization or the account. Substrat money is an `{ amount, currency }` pair that cannot be built without one, so the **binding declares it**, defaulting to `SEK`. It is a value this connector chose, not one it read, and naming it on the binding is what keeps that visible. ### What the sweep does *not* save An unchanged plan skips the **writes**, not the round trips. Planima offers no collection-level `updated_at` or ETag to ask "has anything changed" cheaply, so every pass costs its full request budget whether or not anything moved. Saying so is the point — a connector that implied otherwise would make the sweep interval look free. ## Where the data lands, and why the connector does not decide Pages are global across one sync and each names exactly one facility. A facility's buildings and components ride its **first** page (`facilityHead`); actions ride every page, 500 at a time; `final` marks the last page of the whole sync. A sync that finds **no facilities at all** lands exactly one page with `facility: null`, `actions: []` and `final: true` — a *clear* page. Landing nothing would be the one answer that corrupts a consumer: it swaps on `final`, so a silent pass would leave last month's plan in place for ever while the cursor recorded the empty plan as synced, and no later sweep would repair it. So a consumer upserts on `facilityHead`, appends actions as they arrive, and commits or swaps on `final`. Every page of one sync carries the same `syncId` — which *is* the content hash — so a redelivered page cannot double a cost. What lands is neutral Planima fact: a component, a year, a price, a status string. What a business *means* by any of it — which status counts as committed spend, which category rolls into which budget line, whether a deferred action still books — is vocabulary, and vocabulary is the vertical's layer. A connector that mapped statuses to budget states would be a vertical wearing a connector's clothes, and the second customer who categorises differently would have to fork it. One consequence worth naming: **`status` is passed through unmapped and unvalidated.** Planima types it as a plain `string` even though it documents eight values for the matching filter, so a ninth is an ordinary product change rather than a protocol break. Parsing against a closed set would turn that into a sweep-wide throw — a whole tenant's plan failing to land because one action moved to a status added last week. ## What's missing - **A live verification.** The one that matters. Everything here is read from the OpenAPI document and held up by a mock that shares its assumptions. `connector-fortnox` shipped two wrong premises that way. - **No outbound half.** Planima can create and update organizations, facilities, buildings and components; this connector uses none of it. That is a deliberate first cut — no vertical has asked for a write path, and adding one forfeits the read-only-token argument above. When one does, it belongs behind an event and a dispatch, in the shape `connector-scrive` already has. - **Projects, categories and component types are not synced.** `GET /projects`, `GET /categories` and `GET /component_types` exist and are not read. Actions carry their `project_id` and their category *name*, which is enough for a consumer to group by; the catalogue reads would cost three more requests per pass against a tight budget for data that changes rarely. - **No incremental sync.** The whole window is re-read every pass because the API offers no "changed since" filter. `updated_at` is on each row but cannot be filtered on. --- # What is a vertical? Source: https://substrat.net/verticals/index.md A **vertical** is the business: the software a service firm, a workshop, a shop, or an HR team actually runs on. It owns everything with a user's fingerprints on it — **vocabulary, pricing, roles, screens, workflows** — and it composes the [engines](https://substrat.net/engines/index.md) and the kernel underneath. Where an engine is a reusable *contract*, a vertical is a *composition plus a product*. It is where the three layers meet: the kernel's guarantees, the engines' invariants, and the business's own logic, wired together in one place you can read. ## What a vertical owns (and what it borrows) - **Borrows invariants** from engines by calling their in-scope functions inside its *own* operations — same transaction, so an engine's state machine and the vertical's own tables commit together or not at all. - **Does its own permission check** as the first line of every operation; engines never decide who may call a vertical. - **Adds side tables keyed by an engine's ids** when it needs extra data on an engine entity — never a column upstream (a module's tables are private). - **Owns the screens** — the app is the vertical's, composed over one API; the kernel and engines ship headless. The design test cuts both ways: if a vertical ever needs to *fork* an engine, the engine drew its line wrong; if a vertical hand-rolls tenancy, audit, or permissions, it's reaching below its layer. ## A curated set, not a pile These demos are chosen, not accumulated. Each one exists to prove a **different way of using the platform** — a distinct point in the design space — so the set as a whole shows the range, and no two demos teach the same lesson. | Vertical | Package | The shape it uniquely shows | Engines composed | |---|---|---|---| | **[Meridian](https://substrat.net/verticals/meridian.md)** (HR) | `demos/meridian` | The **shape-breaker** — its *core* domain has no engine, so leave, time and expenses are vertical code on the kernel alone (`protocol` is composed for onboarding only); multi-country scopes diverging from one codebase; one **role-adaptive app** (employee + manager in the same surface) | `protocol` only | | **[Callout](https://substrat.net/verticals/callout.md)** (field service) | `demos/callout` | The **canonical composition** — two engines cooperating through events with zero imports between them (the star-topology showpiece), plus the *pricing moment* where vertical logic meets an engine transition | `workorder` · `invoicing` · `protocol` | | **[Handlebar](https://substrat.net/verticals/handlebar.md)** (bike workshop) | `demos/handlebar` | **Engine reuse** — the same engines under new vocabulary; the second shape that *forced the protocol engine to be extracted* from Callout | `workorder` · `invoicing` · `protocol` | | **[Kallkälla](https://substrat.net/verticals/shop.md)** (coffee shop) | `demos/shop` | **Two audiences, one source of truth** — a customer storefront and a staff back-office as separate apps over one API; `invoicing` reused far outside field service | `invoicing` (+ its own commerce module) | | **[Manyfold](https://substrat.net/verticals/manyfold.md)** (headless CMS) | `demos/manyfold` | **Content lifecycle on the kernel alone** — a draft → review → publish state machine that can't skip, append-only revisions, freeze-on-publish with a content hash, and references resolved at delivery, all **composing no engine at all**; **multi-scope, "site = scope"** — one tenant runs many sites, each its own scope, the same login a different role in each | *none* — kernel only | | **[Todo](https://substrat.net/verticals/todo.md)** (shared lists) | `demos/todo` | **User-initiated sharing on a record app** — sharing one list with one person is `ctx.grant`/`ctx.revoke` on that entity, revocable and transactional with the operation, never an org per row; a **403 wall told apart from an empty list**; and the [walkthrough](https://substrat.net/guide/walkthrough-todo.md) shows every file of it | *none* — kernel only | | **[ticket0](https://substrat.net/verticals/ticket0.md)** (support desk) | `demos/ticket0` | **A public, unauthenticated surface** — an embeddable chat widget confined by a session token and an origin allowlist rather than a login; an **AI assistant as a member of staff** with a principal and a granted role, supervised or autonomous per desk; **honest cost** through an append-only meter only the desk admin may read — the desk on [Ask the docs](https://substrat.net/guide/support.md) is this demo | `metering` | | **[Tock](https://substrat.net/verticals/tock.md)** (measured file loads) | `demos/tock` | **A runtime schema the data may disagree with** — Manyfold also models content types as data but refuses anything that does not match; Tock records the mismatch instead, so a field that arrived undeclared and a field declared that never arrived are both findings. Plus **corrections that supersede without destroying**: a re-run writes a new run, the old one keeps its numbers and the content-hashed rules that produced them, and which run is current is derived rather than stamped. *Runs locally end to end; not deployed* | *none* — kernel only | The through-line: **structurally repetitive, operationally rich** — the foundation is the same, the vocabulary and shape are not. That is exactly the segment Substrat is for. --- # Callout (field service) Source: https://substrat.net/verticals/callout.md `demos/callout` — a small Swedish field-service firm (ElMontage AB, el/VVS installation): work orders, technician time & material, checklists, and invoice basis. It is the platform's **reference vertical** — the one a new vertical is built against. ## Overview Callout is the **canonical composition** — the star-topology showpiece. It is where you see the central claim of the platform work end to end: engines cooperating **through events and opaque `EntityRef`s, with zero imports between them**. Complete a work order and, on the spine, an invoice line appears — built by an engine that never imported the one that emitted the event. Three things it demonstrates that the other demos build on: - **The star topology, running.** Callout composes *three* engines — [`workorder`](https://substrat.net/engines/workorder/index.md), [`invoicing`](https://substrat.net/engines/invoicing/index.md), [`protocol`](https://substrat.net/engines/protocol/index.md) — and none imports another. `workorder.completed` lands on the spine; invoicing consumes it and builds billable lines by **snapshot, not join**, each with drill-down provenance. Editing time entries after completion is refused by the engine invariant — and wouldn't change the invoice line anyway, because it was snapshotted. - **The pricing moment** — vertical logic meeting an engine transition in one transaction. The `callout/complete-workorder` operation runs the vertical's own compliance guard (a `montage` order needs a signed self-inspection protocol, via `requireSigned`), reads the engine-reported lines, prices them from the vertical's `callout_price_list` (min quantities, internal articles dropped), and *then* calls the engine's `completeWorkOrder(ctx, { billable })`. The engine invariant stays intact; pricing is 100% vertical-owned (K-16). Pricing is exactly the kind of thing an engine must never learn. - **The protocol extraction, in place.** Callout's checklists began as vertical code and were extracted into the protocol engine at milestone B — a migration (`0003`) drops the old `callout_protocol_*` tables once the data moves into the engine's own. The extraction handoff is visible in the module. ## At a glance | | | |---|---| | **Package** | `@substrat-run/demo-callout` | | **Engines composed** | [`workorder`](https://substrat.net/engines/workorder/index.md) · [`invoicing`](https://substrat.net/engines/invoicing/index.md) · [`protocol`](https://substrat.net/engines/protocol/index.md) | | **Own tables** | `callout_customers` · `callout_facilities` · `callout_price_list` | | **Roles** | `office-admin` (15 keys) · `technician` (4 keys: `workorder:read`/`report`, `protocol:fill`/`read`) — portal customers hold no role, only an entity-narrowed `workorder:read` grant per customer | | **Permission surface** | [`PERMISSIONS.md`](https://github.com/substrat-run/substrat/blob/main/demos/callout/PERMISSIONS.md) — 19 keys, 4 modules, 2 roles | | **Apps** | node API (`:8871`) + React SPA (`:5271`), started together | | **Status** | Working — the first **CP-less, sandbox-clean pushable** vertical, deployed on Cloudflare | ## The cast & what's denied | Who | Holds | Cannot | |---|---|---| | **Anna** — kontor | `office-admin`, tenant level | — (runs the whole flow: create → assign → complete → export invoice basis) | | **Harald** — tekniker | `technician` at the work scope | **assign** an order, complete it, or sign a protocol — he can only start jobs and report time/material | | **Berit** — BRF Grunden portal customer | no role; entity-narrowed `workorder:read` on her org | see any order that isn't her org's — the portal walk is a **tuple proof-walk**, not a UI filter | | **Styrbjörn** — another portal customer | the same, for his org | see Berit's orders | | **Mallory** — office-admin of *another tenant* | `office-admin` in her own tenant | anything of ElMontage's — the cross-tenant denial | Signing is deliberately kept from the technician: `protocol:fill` is not `protocol:sign`. Filling a checklist and attesting it are different authorities, and the demo splits them. ## Deploying — the CP-less reference Callout is the vertical the [deploy path](https://substrat.net/guide/deploying.md) was proven on. Its `wrangler.jsonc` declares only its **own** stores — its `SCOPE` Durable Object class and an `AUTH_DB` — and *no* `CONTROL_PLANE` binding, no service binding, no platform secret: `assertSandboxContract` would refuse an upload that reached for any of them. It evaluates permissions from its own storage ([scope-local permissions](https://substrat.net/concepts/permissions.md#where-tuples-live-a-scope-reads-only-its-own-state)) and trusts the router-asserted node for tenancy. Its React SPA is **bundled into the worker** (a build step generates `src/assets.ts`), so it needs no static-assets binding either. This is the shape a self-serve vertical takes. ## Run it ```bash pnpm callout-demo dev # API http://localhost:8871 # web http://localhost:5271 ``` The executable spec is `test/scenario.test.ts` — the nine-step headless scenario, including the cross-tenant denial — plus `test/provision.test.ts` over the permission shape. On Cloudflare, `cf:dev` runs it on local Durable Objects and `cf:deploy` ships it (Workers Paid plan for DO SQLite); the same route table drives both, with only the adapter swapped SQLite ↔ DO/D1. ## Deliberately out of scope Scheduling/dispatch (only an assignment field is shown), inventory, supplier invoices / EDI / payroll, and a real Fortnox connector — export writes a stubbed file. The scheduling engine is a [named next candidate](https://substrat.net/engines/index.md#engines-today), not a gap pretending to be filled. --- # Handlebar (bike workshop) Source: https://substrat.net/verticals/handlebar.md `demos/handlebar` — a one-workshop bike-repair shop (Kedja & Kugghjul Cykelverkstad AB, Stockholm): customers bring bikes in, mechanics repair them, the shop prices and invoices the job. ## Overview Handlebar is the **engine-reuse proof**. It is deliberately the *v2 skin* — the **same engines as [Callout](https://substrat.net/verticals/callout.md), under different vocabulary** — so "two verticals on shared engines" is demonstrated rather than claimed. The vertical owns only vocabulary, extra fields, its price list, roles, and screens; nothing in any engine's state machine is touched. A repair *is* a [work order](https://substrat.net/engines/workorder/index.md): `reparation`, on a `cykel` (`facility`), worked by a `mekaniker` (`assigned_to`), priced and invoiced through the same [invoicing](https://substrat.net/engines/invoicing/index.md) path Callout uses. The interesting part is the third engine. ### It forced the protocol engine out Handlebar is the vertical that made [`protocol`](https://substrat.net/engines/protocol/index.md) an engine. Its `tillståndsrapport` — a per-bike condition report — needed the same **sign → immutable** invariant Callout's checklists have, but in a *different shape*: - the **workshop signs** it (freezing the content forever), and the **customer counter-signs the same frozen content at pickup** — a second signature on already-frozen content; - fills are **append-only**; - filling and signing are **different roles** (the mechanic fills; the workshop lead signs). A second shape of the same invariant is exactly what proves an invariant belongs in an engine and not in one vertical's code. So it was extracted — and Handlebar keeps only the template's vocabulary; every invariant (sign-freeze, counter-sign on frozen content, append-only responses, verifiable hash) lives in `@substrat-run/engine-protocol`. That extraction also lets Handlebar lean on **manifest-declared** mechanisms where Callout uses code: a declared guard `protocol/all-signed` (`countersigned: true`) sits `before` `bike-shop/close-repair`, and the vertical `withdraws` the engine's own `workorder/close` so the *only* door to `closed` is the guarded operation. Callout composes the equivalent guard as conditional vertical glue; Handlebar declares it. Two poles of the same seam. ## At a glance | | | |---|---| | **Package** | `@substrat-run/demo-handlebar` | | **Engines composed** | [`workorder`](https://substrat.net/engines/workorder/index.md) · [`invoicing`](https://substrat.net/engines/invoicing/index.md) · [`protocol`](https://substrat.net/engines/protocol/index.md) | | **Own tables** | `bike_shop_customers` · `bike_shop_bikes` · `bike_shop_price_list` | | **Roles** | `workshop-admin` (15 keys) · `mechanic` (4: `protocol:fill`/`read`, `workorder:read`/`report`) — portal customers hold no role, only entity-narrowed `protocol:countersign`/`read` + `workorder:read` per customer | | **Permission surface** | [`PERMISSIONS.md`](https://github.com/substrat-run/substrat/blob/main/demos/handlebar/PERMISSIONS.md) — 19 keys, 4 modules, 2 roles | | **Apps** | node API (`:8872`) + React SPA (`:5272`) — runs side by side with Callout | | **Status** | Working — demo seed | ## The cast & what's denied | Who | Holds | Cannot | |---|---|---| | **Greta** — verkstadschef | `workshop-admin`, tenant level | — (the whole lifecycle: customers, bikes, prices, repairs, invoicing) | | **Måns** — mekaniker | `mechanic` at the work scope | **assign**, complete, or invoice — only start repairs and report time & parts | | **Lisbeth** — portal customer (Crescent owner) | no role; entity-narrowed grants on her own customer | see Otto's repairs, write anything, or invoice — she sees her own repairs + timeline and **counter-signs** her report | | **Otto** — portal customer (Bianchi owner) | the same, for his customer | see Lisbeth's repairs | | **Rutger** — admin of rival tenant *Trampolin Cykel AB* | `workshop-admin` in his own tenant | anything here — `unknown scope` on a forged pair, `permission denied` on every operation | Provisioning yields **only** what a customer gets — one tenant, one scope, roles, and an owner with `workshop-admin`. The named cast and the rival tenant are demo fixtures, structurally unreachable from provisioning (`provisionHandlebar` returns a shape with no room for a cast). ## Run it ```bash pnpm --filter @substrat-run/demo-handlebar dev # API http://localhost:8872 # web http://localhost:5272 ``` Two suites: `test/provision.test.ts` (one tenant/one scope, only the owner gets a role, idempotent) and `test/scenario.test.ts` — the 13-step scenario covering the priced completion, the star-topology invoicing event, portal isolation, immutable export, the withdrawn `workorder/close`, append-only fill with the fill/sign split, sign-freeze + customer counter-sign on the same content, and the manifest guard gating pickup. --- # Kallkälla (coffee shop) Source: https://substrat.net/verticals/shop.md `demos/shop` — a small-batch coffee roaster in Stockholm (Kallkälla Kaffe AB) running a web shop: beans (whole/ground × 250 g/1 kg) and brewing gear, checkout **mot faktura** (on invoice). ## Overview Kallkälla is deliberately the *third* vertical, doing a different job than the work-order shops ([Callout](https://substrat.net/verticals/callout.md), [Handlebar](https://substrat.net/verticals/handlebar.md)). It proves three things they can't: - **A published engine reused across a genuinely different domain.** [`invoicing`](https://substrat.net/engines/invoicing/index.md) builds an invoice basis from a **retail order**, not a service work order — the same snapshot-not-join consumer, fed by a different event. Star-topology reuse, outside the domain the engine was extracted from. - **Additive engine evolution, on stage.** Invoicing learns a *second* input event (`commerce.order-placed`) **additively** — a new `consumes` entry and a second consumer with its own Zod parse — while the existing `workorder.completed` path stays frozen and untouched. This is the [additive-evolution rule](https://substrat.net/concepts/modules.md) demonstrated, not asserted. - **A new invariant shape: no oversell.** Not a state machine — a **reservation ledger**. You cannot sell more than exists. ### The no-oversell invariant `available = on_hand − Σ(active reservations)`. A cart add succeeds only if `available ≥ qty`, else it throws out-of-stock. Reservations are lines on an **open cart**; expiry is **lazy** — elapsed holds are excluded on read and swept opportunistically on the next write, so there is no timer or cron (default hold 900 s). Checkout re-verifies `on_hand` in case a hold lapsed. It is enforced in the vertical's **own commerce module**, not an engine — and it is atomic for the same reason the booking engine's hold is: operations serialize per scope (K-6), so "read available, then reserve" never interleaves. This is the extraction seam for a future `engine-inventory` / `engine-order` — named for the *second* retail vertical (decision 27), not built ahead of one. Its companion invariant: an order is **immutable after placement** (`cart → placed → fulfilled → closed`, no skips). ## At a glance | | | |---|---| | **Package** | `@substrat-run/demo-shop` | | **Engines composed** | [`invoicing`](https://substrat.net/engines/invoicing/index.md) (+ the vertical's own commerce module) | | **Own tables** | `shop_products` · `shop_variants` · `shop_stock` · `shop_customers` · `shop_discounts` · `shop_carts` · `shop_cart_lines` · `shop_orders` · `shop_order_lines` | | **Roles** | `public` (browse) · `shopper` (browse + checkout) · `warehouse` (fulfil, read, stock) · `shop-admin` (all) — portal customers hold an entity-narrowed `order:read` grant | | **Permission surface** | [`PERMISSIONS.md`](https://github.com/substrat-run/substrat/blob/main/demos/shop/PERMISSIONS.md) — 10 keys, 2 modules, 4 roles | | **Auth** | [OIDC-only](https://substrat.net/concepts/identity.md#two-real-choices-made-differently) — login lives at the issuer (locally the [dev issuer](https://substrat.net/concepts/identity.md#the-dev-issuer-local), whose `/authorize` lists [the cast](https://substrat.net/concepts/identity.md#in-the-demo)), and the vertical only maps the authenticated `sub` → a principal; plus an anonymous browse-only principal, which is not a credential store | | **Apps** | **four** processes over one API: the dev issuer (`:8879`, `ISSUER_PORT`) first, then API (`:8873`), storefront (`:5273`), back-office (`:5274`, `ADMIN_PORT`) | | **Status** | Working — demo seed | ## Two audiences, one source of truth The storefront and the back-office are **separate Vite apps** — different chrome, different audience — both proxying `/api` to **one** API, so every action runs the same kernel permission check behind both. The split is presentation and audience, never a second source of truth. This is the sharpest illustration in the repo that "customer-facing" and "staff-facing" are surfaces over one authority. ## The cast & what's denied | Who | Holds | Cannot | |---|---|---| | **Astrid** | `shop-admin`, tenant level | — (catalog, prices, stock, discounts, fulfil, invoicing) | | **Gustav** | `warehouse` | **set prices**, create discounts, or touch **invoicing** — he adjusts stock and fulfils orders | | **Elin** — Café Pascal | entity-narrowed `order:read` on her own customer | see Otto's orders, write anything, or invoice — she sees only her own orders | | **Otto** — Kontoret | the same, for his customer | see Elin's orders | | *(not logged in)* | `public` — browse-only | anything but browsing the catalogue — a thin role on the same code path, not an auth bypass | | **Rurik** — admin of rival tenant *Bönfeber* | `shop-admin` in his own tenant | anything of Kallkälla's — the cross-tenant denial | Signing in as Gustav and watching *Invoice basis* disappear from the nav — and 403 if you ask for it directly — is the whole thesis in one click: **the issuer authenticated you, the kernel authorized you.** ## Run it ```bash pnpm --filter @substrat-run/demo-shop dev # issuer http://localhost:8879 # API http://localhost:8873 # storefront http://localhost:5273 # back-office http://localhost:5274 ``` Two suites: `test/scenario.test.ts` (the oversell throw on the last-unit race, checkout mot faktura with a discount, frozen order lines, the invoicing underlag with provenance, lazy TTL release, the no-skip state machine, and every denial vector) and `test/provision.test.ts`. ## Deliberately out of scope Payment capture (Swish/card), tax, shipping rates, multi-warehouse, and returns — a card/Swish order would settle through a deferred payment connector, and only **mot faktura** produces an invoice basis. The extracted inventory/order engines wait on a second retail vertical. --- # Meridian (HR) Source: https://substrat.net/verticals/meridian.md `demos/meridian` — a multi-country **HR vertical**: leave and absence, project time reporting, expenses, onboarding, and the **anställningsavtal**, in **one role-adaptive app** that is an employee's self-service tool and a manager's console at once. ## Overview Meridian is the deliberate **shape-breaker**. Every other demo leans on a domain engine for the work at its centre; Meridian has *none to lean on* — there is no absence engine, no time-tracking engine, no expenses engine. So leave/absence, time reporting, and expenses are all **vertical code**, and that is the point: it proves the kernel's guarantees — nested tenancy, permissions, audit, GDPR — hold with **zero engine support**, and that Substrat's value isn't quietly borrowed from the work-order state machine. It's interesting for three reasons the field-service demos can't show: - **A core domain with no engine.** The kernel carries leave, time and expenses alone. It does compose one engine — [`protocol`](https://substrat.net/engines/protocol/index.md), for onboarding checklists **and the employment contract** — but that sits beside the work rather than under it, and is itself the third proof of protocol reuse. - **Two countries from one codebase.** Sweden (25 days + saved days, VAB) and Spain (22 days, *registro de jornada*) are two scopes under one tenant, diverging by **data and per-scope config**, not a fork. - **One app, two audiences.** An employee sees only their own record; a team lead who is *also* an employee gets a **Manage** section beside their own **My work** — the same app, the same permission checks, adapting to who signs in. And it surfaces the platform's next engine: the **absence / entry-ledger** candidate ([engines](https://substrat.net/engines/index.md#engines-today)) — because its absence ledger and its time ledger are the *same append-only shape* the work-order engine already owns. ## At a glance | | | |---|---| | **Package** | `demos/meridian` (private) | | **Tenancy shape** | 2 tenants — Nordljus AB (Sweden + Spain scopes) · Solmark AB (the cross-tenant attack victim) | | **Engines composed** | [`protocol`](https://substrat.net/engines/protocol/index.md) — onboarding checklists **and** the anställningsavtal, both bound to an `employee` ref | | **Own tables** | `hr_employees` · `hr_employment_terms` (append-only) · `hr_leave_types` · `hr_absence_ledger` (append-only) · `hr_leave_requests` · `hr_projects` · `hr_time_entries` (append-only) · `hr_expenses` · `hr_holidays` | | **Roles** | `hr-admin` (tenant) · `manager` / `payroll` (scope) — employees are entity-narrowed grants, not a role | | **Permission surface** | [`PERMISSIONS.md`](https://github.com/substrat-run/substrat/blob/main/demos/meridian/PERMISSIONS.md) — 21 keys, 3 roles | | **App** | one role-adaptive React app (employee mobile + manager web), fjord-blue design system, light + dark | | **Status** | Working — 14-case scenario green on the pure-SQLite adapter | ## Engines composed Only one, and it's the reuse story: **`protocol`** — used **twice, in both of its content kinds**, which makes Meridian the demo that shows the difference. ### A checklist: onboarding `hr/start-onboarding` calls the engine's `instantiateProtocol(ctx, …)` bound to an **`employee`** `EntityRef`, and the vertical declares the `protocol → employee` relation so the permission walk reaches the employee's own-record grant. A nice twist: onboarding is signed by the **employee** themselves (their own acknowledgement), not a supervisor — the same engine as Callout, where the arbetsledare signs; the vertical's grant decides who holds `protocol:sign`. ### A document: the anställningsavtal An employment contract is not checklist-shaped. It has articles — a role, a salary, an occupancy rate, a start date, a notice period — and they live in **this vertical's** `hr_employment_terms` table. The engine never sees them. It gets a hash. This checklist item used to exist: ```ts { key: 'anstallningsavtal', label: 'Anställningsavtal signerat', type: 'check' } ``` It's gone, and its removal is the lesson. **A checkbox recording that a contract was signed is not evidence that it was.** The tempting fix — one item reading "I accept this contract", with the real terms in the vertical — is worse than it looks: the engine's hash would cover that sentence and nothing else, producing a signature that *looks* like evidence and isn't. So the contract is a `document` protocol. `hr/issue-employment-contract` does three things in one transaction: instantiate, `bindDocument` with `(contentRef, contentHash)`, and `requestSignatures`. > **Warning — What the engine proves, and what it doesn't** > > It proves a signature was made over exactly this hash, at this time, by this signatory, and > that the hash has not moved since. It **cannot** prove `hr_employment_terms` still hashes to > it — it never read that table. > > Re-deriving the hash is the **vertical's** obligation. That is why `hr/verify-contract` > exists, and why the template's `hashRecipe` is a required field spelling the recipe out for > whoever opens this in five years. ### Two signatories, two kinds The asymmetry is the point, and HR is where it's most obviously true: | Party | Kind | Why | |---|---|---| | **Arbetsgivaren** | `principal` | has an account, signs as themselves — the issuing party, so `primary` | | **Den anställde** | `external` | a new hire has **no login on the day they sign** | `hr_employees.principal_ref` has always been nullable — "the login principal, if this person has one". Karin Berg is seeded with none: offered a job, contract out at Scrive, not yet an employee. Her signatory ref is the opaque **employee id**, the same `DataSubjectId` `hr.employee-created` already crypto-shreds on — **never `national_id`**, which is this vertical's declared shred target and would become permanent in a table nothing can edit. While the contract is out it sits in `pending_signature`: **frozen**. No rebind, no fill. It reaches `signed` only when *both* parties have signed — one signature, or a decline, is not completion. > **Warning — Nothing completes this in the *local* demo, and that is not a platform gap** > > `protocol/record-signature` is what a Scrive webhook calls. Both blockers this page used to > name are now closed: webhook ingress terminates on the platform > ([#96](https://github.com/substrat-run/substrat/issues/96)) and a non-principal caller reaches > a scope operation through a [connection's stub](https://substrat.net/concepts/permissions.md) > ([#97](https://github.com/substrat-run/substrat/issues/97)) — a subject that opens the door > and confers nothing, whose events are stamped with the connection that caused them. > > What is still true is the *local* story: `pnpm … dev` has no provider on the other end, so a > contract dispatched there stays frozen and pending. Completing it needs the > [Scrive connector](https://substrat.net/connectors/scrive.md) and a real signing session. > > `hr-admin` holds `protocol:bind` and `protocol:request-signature` — it can freeze and > dispatch. It does **not** hold `protocol:record-signature`, and neither does any other human > role: asserting that someone signed with BankID is the provider's word, not HR's. The scenario > test mints a throwaway principal to stand in for the connector, and that stub deliberately > lives in the **test** rather than the seed — a seeded "connector principal" in a reference > implementation would quietly become an authority nobody designed. Everything else is vertical code — and the most interesting part is what *isn't* an engine yet. **Two ledgers, one shape:** - `hr_absence_ledger` — vacation/absence, where an entry moves a **balance** (accrual `+`, booking `−`, correction, carryover). Balance is a fold; the current value is never stored. - `hr_time_entries` — worked hours booked to a **project** ref, where an entry moves no balance. Both are append-only (a correction is a new row), both fold to a value, both emit an event per write — the *identical discipline* the work-order engine already owns for order-bound time. That three unrelated capabilities want the same invariant is the signal that an **entry-ledger engine** should be extracted once a second HR-shaped consumer forces it. Its generic core may even belong in the kernel (a ledger-entries attachment contract); the domain semantics — accrual, the approval state machine, the no-negative-beyond-policy floor — stay in the engine. Until then it is honest vertical code with the invariants written cleanly, so the extraction is later mechanical. The variable-pay **payroll export** is the Callout *invoice basis* pattern re-cast: approved absence + expenses leave as a file for a payroll provider. Payroll itself is a deep-domain moat — integrated, never rebuilt. ## The cast & what's denied Six principals span the whole matrix — pure employee, dual-role, pure manager, and an attacker: | Persona | Holds | Sees | |---|---|---| | **Elin** (SE) · **Pablo** (ES) | entity-narrowed grant on their own `employee` record | **My work** only — their own balance, timesheet, expenses, onboarding | | **Karin** (SE) | *nothing* — no principal at all | she is a signatory, not a user: her contract is out for BankID signature before she has an account | | **Mats** — team lead | `manager` role **and** his own employee record | **My work + Manage** — the dual-role case | | **Petra** | `payroll` role | the variable-pay export | | **Hedda** | `hr-admin` role (tenant-level) | **Manage** across both country scopes | | **Mallory** | `hr-admin` in *Solmark AB* | nothing of Nordljus — the attacker | The denials the [scenario](#run-it) asserts — because denials *are* the demo: - an employee **cannot approve their own leave**, read a colleague's balance, or enumerate the team (`hr/roster` is gated on node-level `absence:read`, which employees hold only per-entity); - a manager **sees their department but never compensation** — the roster read carries no `national_id` or salary; - **cross-tenant**: Mallory operating from Solmark's scope reaches *none* of Nordljus's data — the isolation holds structurally, not by a filter someone remembered to add. The full surface — every key, which role holds it, and the entity-narrowed grant shapes — is the checked-in [`PERMISSIONS.md`](https://github.com/substrat-run/substrat/blob/main/demos/meridian/PERMISSIONS.md), re-emitted by CI so it can't drift from what runs. ## The app One React app, composed over the vertical's single API, that **adapts to the principal and the viewport**. The nav is gated on what you *are* — an employee record grants **My work**, manager/HR permissions grant **Manage** — and the layout switches with the screen: ```mermaid flowchart TB subgraph one["One role-adaptive app · one /api · one permission check"] direction TB W["My work
Home · Time off · Timesheet · Expenses · Me"] M["Manage · dept
Inbox · Team calendar · Timesheets · Onboarding · Team"] end Elin["Elin / Pablo
(employee)"] --> W Mats["Mats
(team lead)"] --> W Mats --> M Hedda["Hedda
(HR admin)"] --> M W -. "bottom tabs on mobile" .- L["Responsive shell"] M -. "sidebar on desktop" .- L ``` - **Employee (mobile-first)** — a vacation-balance hero, and four one-handed, low-typing flows: request time off (with live balance impact), log time (append-only, hours stepper), submit expense (camera-first), and e-sign onboarding. - **Manager (desktop-led)** — an approvals **Inbox** (decline always takes a reason), a team **absence calendar**, **timesheet** review, **onboarding** tracking, and a salary-free **team** roster. Onboarding cards distinguish the two protocol kinds: a checklist shows a progress bar, a contract shows *"Frozen at Scrive · 2 signatures outstanding"* — because rendering a document as `0/1` would misdescribe what is actually happening to it. - **Design system** — the fjord-blue "Meridian" palette, a colourblind-safe leave-type palette (every colour paired with a shape), light and dark. The employee and manager surfaces read as one system because they *are* one app. ## Run it ```sh pnpm --filter @substrat-run/demo-meridian dev ``` Starts the API on `:8875` and the app on `:5275`. Open it, resize the window to watch the shell move between bottom-tabs and sidebar, and switch personas (Elin → Mats → Hedda) to watch the sections adapt. The executable spec is the scenario test — fourteen cases replayed headlessly on the pure-SQLite adapter: ```sh pnpm --filter @substrat-run/demo-meridian test ``` It asserts the ledger fold (request → approve drops the balance 25 → 22), the approval state machine (can't skip, can't re-decide), the no-negative floor, the self-service and cross-tenant **denials**, onboarding reuse, Sweden/Spain diverging from the same code, and the contract flow end to end — that a document is bound by hash and never read by the engine, that `pending_signature` refuses a rebind, that two signatories of *different kinds* are needed before it is signed, that a provider reporting a hash we never froze **fails closed**, and that HR cannot speak for the provider. The `PERMISSIONS.md` and the append-only migrations (`0001-init`, `0002-employment-terms`) are the human checkpoints the platform makes unskippable. --- # Manyfold (headless CMS) Source: https://substrat.net/verticals/manyfold.md `demos/manyfold` — a **multi-scope headless CMS**: content types authored as data, a draft → review → publish editorial lifecycle, append-only revisions, and a frozen published projection served by (type, slug) — one tenant running **many sites, each its own scope**, in a single content-studio app over one API. ## Overview Manyfold is the demo that puts two ideas in one place: a **content lifecycle** and **multi-scope tenancy where the scope *is* the site**. A publishing studio runs several client sites from one account; each site is a scope, and the same login is a different principal — with a different role — in each. Café Nordlys, Padel Nordic and a law firm share one codebase and one tenant, and nothing published on one is visible from another. Like [Meridian](https://substrat.net/verticals/meridian.md), it is a **shape-breaker: its core domain composes no engine at all.** There is no CMS engine to lean on — the editorial state machine, append-only revisions, the freeze-on-publish hash, and reference resolution are all **vertical code on the kernel**. That is deliberate (decision 27): the engine extraction waits for a *second* content vertical to force it, the same "second consumer, different shape" signal every other engine was extracted on. So the invariants are written cleanly in the vertical now — a state machine that can't skip, revisions that only ever append, a projection kept consistent inside the publish transaction — positioned so the later extraction is mechanical rather than a rewrite. Until then, Manyfold is the proof that the kernel's guarantees (nested tenancy, permissions, the event spine, per-scope isolation) carry a whole domain with **zero engine support**. It is interesting for reasons the field-service demos can't show: - **Site = scope.** One tenant, many scopes, and the multi-tenancy is *within* a single customer rather than across them. Provisioning creates one tenant and N sites; a login is granted a role per site, and reachability — not a `WHERE site = ?` filter someone remembered — is what keeps one site's content out of another. - **A lifecycle that freezes.** Publishing takes a content hash over the exact revision and marks it immutable; any later edit is a *new* revision, never a mutation of history. The published projection serves that frozen body, so what a reader sees can't drift out from under the hash. - **Content types are data, not code.** The four default types are seeded on first use; an admin authors new ones at runtime, and each save bumps the type's version. A `ref` field is resolved **at delivery** against the published projection — a link to a draft comes back as an explicit *unresolved* marker, a broken link shown honestly rather than hidden. ## At a glance | | | |---|---| | **Package** | `demos/manyfold` (private) | | **Tenancy shape** | 1 tenant — Nordlys Studio · **3 sites, each its own scope**: Café Nordlys · Padel Nordic · Lindqvist & Ruiz | | **Engines composed** | *none* — the editorial lifecycle is vertical code on the kernel alone (decision 27; the CMS-engine extraction waits for a second content vertical) | | **Own tables** | `manyfold_entry` · `manyfold_revision` (append-only) · `manyfold_status_log` (append-only) · `manyfold_delivery` (published read model) · `manyfold_content_type` (types are data) | | **Roles** | `admin` (owner, tenant-wide) · `publisher` / `editor` / `author` / `viewer` (per site) — a role ladder, no entity-narrowed grants | | **Permission surface** | [`PERMISSIONS.md`](https://github.com/substrat-run/substrat/blob/main/demos/manyfold/PERMISSIONS.md) — 5 keys · 5 roles | | **App** | one content-studio React app over one API, signed in through OIDC (locally, the dev issuer's persona picker) with a **site** switcher (`x-site`) that selects the scope — never the caller | | **Status** | Working — a 10-case scenario green on the pure-SQLite adapter | ## No engine, and why that's the point Every other demo borrows an invariant from an engine — a work-order state machine, an allocation arbiter, an invoice basis. Manyfold's central invariant is the **editorial lifecycle**, and there is no engine that owns it. So the vertical does, and it does so under exactly the discipline an engine would enforce: - a **state machine** that rejects any transition not on the allowed graph; - **append-only** revisions and an append-only status log — a correction is a new row; - **immutable-after-publish**: the published revision is frozen with a content hash; - **every mutation emits a fat event** on the spine. That the same four properties keep reappearing — here, in Callout's work orders, in Meridian's ledgers — is the extraction signal itself. The line Manyfold draws for a future `content` / editorial engine is visible in `module.ts`: the lifecycle helpers (`transition`, the `ALLOWED` graph, `contentHash`, the delivery projection) are the piece that would move; the content-type *modelling* and the vocabulary would stay in the vertical. It isn't extracted yet **on purpose** — a single consumer is a guess, and D-27 is the rule against designing an engine off one demo's wishlist. ## The editorial state machine An entry's `status` moves along a fixed graph; `transition` refuses anything off it and writes an append-only `manyfold_status_log` row for every hop: ``` draft ──▶ in_review ──▶ approved ──▶ published ──▶ unpublished │ │ │ │ │ │ ▼ ▼ ▼ ▼ └────────▶ archived (terminal) ◀─────────────────────── (in_review can bounce back to draft; unpublished re-enters at in_review) ``` The guards **move with state**, which is the property the scenario pins: - `publish` requires an entry to be **`approved`** — a fresh draft can't skip straight to published, and neither can one merely submitted for review. - Only a `draft` or `unpublished` entry takes a new revision; editing a `published` one is refused, because its revision is frozen. - `approve` / `reject` are `content:review` acts; a rejection **requires a note**, which lands on the `draft` transition it causes. ### Freeze on publish `publish` computes a SHA-256 over `(typeKey, revNo, canonical body)` — Web Crypto, never `node:crypto` — flips the revision's `frozen` flag, records the hash, and upserts the **delivery** projection in the *same transaction*. From then on the published body is immutable; any change authors a new revision against a new number. `unpublish` and `archive` remove the entry from delivery. The read model can never show content that disagrees with the frozen hash, because it's only ever written from the freeze. ## Content types are data The four defaults — `author`, `page`, `post`, `snippet` — are seeded lazily on first use, and a `content:admin` may author more at runtime (`manyfold/save-type`). A type names its fields, its title field, an optional slug field, and `ref` / `refMany` fields that target other types. Every save **bumps the type's version** (schema evolution is a new version); an entry body is validated against its type's generated Zod schema at the boundary, so an unknown field is rejected. Milestone A persists bodies as JSON, so adding a field is free — the typed-table migration each type *compiles to* (`compileTypeToSql`) is kept as the reviewable artifact, not run as a live `ALTER`. ### Delivery, and references resolved late `manyfold_delivery` is the public read model: **only published, frozen content**, keyed by `(type, slug)`. `manyfold/deliver` reads it and resolves each reference field against the projection — a target that is published comes back as `{ $ref, type, slug, title }`; a target still in draft (or archived) comes back as `{ $unresolved: true, reason: 'not_published' }`. A broken link is shown honestly rather than silently dropped, and a reference "heals" the moment its target is published, without touching the referring entry. ## Roles & permissions Authority is a **role ladder**, held per site (K-22: the same login is a different principal, with a different role, in each scope). There are **no entity-narrowed grants** — this vertical's access is node-level, not per-entity, which is itself a contrast with Meridian and Todo: | Role | Holds | Can | |---|---|---| | `viewer` | `content:read` | read entries, revisions, models | | `author` | + `content:author` | create/edit drafts, submit for review, restore revisions | | `editor` | + `content:review` | approve or reject entries in review | | `publisher` | + `content:publish` | publish, unpublish, archive | | `admin` | + `content:admin` | manage members, roles, and content **models** | The five keys are `content:read` · `content:author` · `content:review` · `content:publish` · `content:admin`. The installing owner holds `admin` tenant-wide (`scopeId: null`) — admin of every site from day one; everyone else is assigned a role **per site**. The full surface — every key and which role holds it — is the checked-in [`PERMISSIONS.md`](https://github.com/substrat-run/substrat/blob/main/demos/manyfold/PERMISSIONS.md), re-emitted by CI so it can't drift from what runs. ## Events Every lifecycle transition emits a fat event and consumes none: `content.submitted` · `content.approved` · `content.rejected` · `content.published` · `content.unpublished` · `content.archived`. `content.published` is the fat event a **webhook connector** would consume to purge a CDN or trigger a static rebuild — that consumer is host code, the documented next step, not module code. ## The app One content-studio React app, composed over the vertical's single API. The twist over the other demos is **multi-scope**: *who* you are comes from the login — an ordinary OIDC session, which locally is [`@substrat-run/dev-issuer`](https://substrat.net/reference/dev-issuer.md) rendering the personas in `src/personas.ts` as a picker — and *where* you are working comes from the in-app **site switcher**, which sends `x-site` (a site slug the server resolves to one of the tenant's scopes). Selection is not authentication: the login says who, the site says where, and the kernel re-checks your authority in that scope either way — a slug can only ever reach a scope of the tenant you are linked to. There is no `x-principal` header; the one that stood here defaulted the app to "already signed in as somebody", which is the one thing a hosted instance never does. The app gates its own chrome on `manyfold/whoami`, which reports what the current principal may do *in this site* — so the same login sees author tools on one site and read-only on another. The seeded personas make that concrete: | Persona | cafe | padel | law | |---|---|---|---| | **Maja Lindqvist** (owner) | admin | admin | admin | | **Emil Berg** | publisher | author | viewer | | **Sofia Ruiz** | author | — | — | Switching Emil between sites is the demo: the same human, a different role — and on a site where he holds nothing, denied even to read. ## Run it ```sh pnpm --filter @substrat-run/demo-manyfold dev ``` Starts the dev issuer on `:8879`, the API on `:8876` and the app on `:5276` (all in the private `887x` / `527x` block; override with `ISSUER_PORT=… PORT=… WEB_PORT=…`). Two different controls do two different things: the in-app **site switcher** changes which scope the *current* OIDC session works in — same person, possibly a different role — while **Switch user** goes back to the issuer and signs in as a different subject. Sign in as a persona, then move between sites to watch one login's authority change scope by scope; switch user to see another person's. A script acts as someone by minting a bearer at the issuer (`POST http://localhost:8879/dev/token {"sub":"dev|emil"}`), never by telling the vertical who it is. The executable spec is the scenario test — ten cases replayed headlessly on the pure-SQLite adapter: ```sh pnpm --filter @substrat-run/demo-manyfold test ``` It asserts the module journal applied per scope; that an admin can model a new content type (with a reference) and it drives `create-entry` immediately while an author cannot model; append-only revisions and a restore that copies an old body into a *new* revision without mutating history; the workflow denials (author can't approve or publish, a viewer can't write, a login with no role on a site is denied even to read); publish freezing a revision with a verifiable hash and filling the delivery projection; immutability and the no-state-machine-skips guards; references resolving late (draft = unresolved, then resolved once the target publishes); scope isolation (publishing on one site leaves the others with no delivered content); and that every mutation landed on the spine, in order. The `PERMISSIONS.md` and the append-only migrations (`0001-init`, `0002-content-types`) are the human checkpoints the platform makes unskippable. --- # Todo (shared lists) Source: https://substrat.net/verticals/todo.md `demos/todo` — the smallest vertical that is still a real one: lists, the items on them, and sharing a list with one person by email. No engine, no money, one role, two tenants. ## Overview Todo exists for a shape none of the other demos show — a **record app with user-initiated sharing** — and for a reason a todo app is usually a bad demo: the reader spends no attention on the domain, so the platform is the only thing on the page. It proves: - **Per-record sharing is two kernel calls.** Sharing "Groceries" with Björn is `ctx.grant(principal, 'list:contribute', listRef)` in `src/module.ts`; revoking it is `ctx.revoke(…)` with the same three arguments. The grant is **entity-narrowed** (this list, not all lists), **delegating** (the caller's own right to that list is re-checked), and **transactional** with the operation. Neither alternative fits: a `ctx.link` edge is permanent, and org membership is a whole org rather than one record — so Todo is the reference against minting an org per row to get a revoke. Read it before designing any "share this with a person" feature. - **A 403 wall is not an empty list.** A list you were never shared reaches you neither in the listing nor by id — asking directly is *refused*, not answered with nothing — and the React app tells the two apart on screen. - **No engine is a fact, not an omission.** An item is open or done and can go back; there are no stages to skip, so there is nothing for an engine to own. What Björn may do is decided by the share on *that list*, not by anything about Björn. - **The whole process, as whole files.** [Walkthrough: the todo app](https://substrat.net/guide/walkthrough-todo.md) shows this vertical end to end — brief, interview, approved concept, declared model, what is derived, and the one file of business logic — pulled from `demos/todo/` at build time. ## At a glance | | | |---|---| | **Package** | `@substrat-run/demo-todo` | | **Engines composed** | *none* — kernel only | | **Own tables** | `todo_owners` · `todo_lists` · `todo_items` · `todo_shares` | | **Roles** | `member` (create lists) — everything else is an entity-narrowed grant: `list:contribute` on a list shared with you; `list:contribute` + `list:manage` on your own | | **Permission surface** | [`PERMISSIONS.md`](https://github.com/substrat-run/substrat/blob/main/demos/todo/PERMISSIONS.md) — 3 keys, 1 module, 1 role | | **Auth** | [OIDC only](https://substrat.net/concepts/identity.md) — the dev issuer is a real provider you sign into by picking a name; there is no dev auth branch | | **Apps** | API (`:8878`) + one React app (`:5278`) for owners and invitees alike — no separate portal | | **Status** | Working — demo seed | ## The cast & what's denied | Who | Holds | Cannot | |---|---|---| | **Ada** | `member`, tenant one — owns *Groceries* and *Work* | — | | **Björn** | `member`, same tenant; *Groceries* is shared with him | **delete the list**, delete items, share it onward, or see *Work* — not in his listing, and refused by id | | **Cleo** | `member` in a different tenant | anything of Ada's or Björn's — not by listing, not by id; the cross-tenant denial | The control beside each closed door: Björn *can* add bread and tick milk off, so the denials are not passing because every door is shut. Revoking his share takes effect immediately. ## Run it ```bash pnpm --filter @substrat-run/demo-todo dev # API http://localhost:8878 # web http://localhost:5278 (sign in by picking Ada, Björn or Cleo) ``` `test/scenario.test.ts` replays the happy path and every denial above; `test/server.test.ts` drives the HTTP layer; `test/entity-checks.test.ts` generates, from the declared model, the behavioural pair that proves each handler honours its entity-scoped permission — Björn as the probe, admitted to Ada's lists one grant at a time. ## Deliberately out of scope Due dates, reminders and recurrence; sub-tasks, attachments, comments, re-sharing, public links and search. A list is shared with a **person**, never a link. --- # ticket0 (support desk) Source: https://substrat.net/verticals/ticket0.md `demos/ticket0` — an AI-assisted support desk: a chat widget a company embeds on its own site with one ` ``` Its first week in production made it better in the ways a first week does: a desk with no owner now shows its login instead of hiding it; the knowledge base has an add-source form and says on the row when a read fails; the allowed widget origins are managed in Settings; opening the widget opens a session, not a conversation, so crawlers no longer fill the inbox; a merge can no longer cross two contacts; the assistant's failures are visible in the desk with their reason; and a visitor's device, language and rough location are recorded once, in a runtime-neutral shape, so a reply can say "you were on the pricing page". It is also the first vertical on the platform model host above: a desk now picks a model in **Settings → Assistant**, which shows the hosting disclosure and, if the platform holds no credential for that provider, says exactly which one is missing while the desk keeps answering extractively. ## For hosted operators - **The owner seat has a window.** A hosted vertical's first owner used to be whoever completed the sign-in first, with no time limit. Now that first-sign-in claim is open for **fifteen minutes after provision**; after that, the seat is claimed only through a link you mint from the dashboard's Owner-seat card. A re-provision no longer re-opens the seat. If you have an instance that was provisioned earlier and never claimed, its window now reads as closed — mint a claim link. - **The router's shared secrets fail closed.** A vertical that reaches the fleet without its `ROUTER_SECRET` now refuses every routed request instead of believing the tenant and scope headers it was handed, and the router refuses to dispatch at all without one. That makes the ordering of a secret rotation load-bearing for availability, not only for safety — which is why the rotation command now finishes the job: it re-puts the shared pair on every script in the dispatch namespace itself, paginating past the first hundred, instead of printing a reminder that once had to be acted on by hand. - **The control plane's dispatch namespace has no default.** It is stated per environment and throws when unset. Unset, a test control plane had been uploading pushed scripts into the *production* namespace while serving from the test one. - **Deploying the dashboard invalidates outstanding invite links.** Invite tokens and OAuth state now sign with per-purpose keys derived from `SESSION_SECRET` rather than the raw session key. Pending invites stay pending in the roster; resend them. Nothing to rotate. - Hostnames resolve identically on both adapters — one place decides what an active hostname points at — and a preview or snapshot bind now mirrors the surface its source app is bound to instead of assuming `app`, so a vertical that declares surfaces but no `app` gets a working preview URL. - In the console, the developer actor override exists only in a development build, and the control-plane client sends one credential rather than a token and an actor header together. ## Also - **The demo issuer** (`demos/auth-server`) speaks OAuth 2.1, has an Applications panel where registered apps can be listed, edited and have their secrets rotated, and gained a self-service sign-up switch that defaults to off. Its login and consent pages, long pointed at, now exist. - **Auth composition is one call.** `@substrat-run/vertical-auth` exported the primitives but not the composition, so four demos each carried their own copy of "read the delivered config, resolve the declared settings, pick a provider" — and they had drifted. `instanceAuthFor(...)` replaces all four. The drift it closes is worth knowing if you deploy Manyfold: it read its OIDC issuer straight from a worker binding, so a per-install issuer saved in the dashboard never reached it. - **Deploying from CI** works again for a vertical inside a monorepo: the generated workflow builds the workspace packages it depends on, and the CLI hands wrangler a config path it can find. The permission and API gates run standalone for a project outside the monorepo, so a scaffolded project keeps them in its own CI. - **`useAutoRefresh`** in `@substrat-run/ui` refetches a screen when a grant is revoked under it, so someone removed from a list stops seeing it; todo and the shop adopt it. - **RallyPoint's money is exact.** The demo computed öre, per-player shares and revenue through floats, which is wrong for exactly the amounts a price list holds. It is decimal and bigint arithmetic now — worth copying if you took the wallet seam from it. - **The changelog has a back catalogue.** Weeks 29 through 34 are published, so the record now starts at the week the platform got its name rather than last Monday, and week 34 was rewritten in the shape the later entries use. - **Docs**: this changelog, now reachable from the site navigation; the todo app as a full walkthrough — concept, model, derived artifacts, business logic — with the real files pulled in at build time; the sharing primitives (`ctx.grant` / `ctx.revoke`) named everywhere an agent decides how sharing works; the CLI reference now covers the push refusal for an unserved UI and its `--allow-unserved-ui` override; the owner-seat rule above is stated on the [deploying guide](https://substrat.net/guide/deploying.md) and the [dashboard page](https://substrat.net/platform/dashboard.md#owner-seat) — the two pages you read when wiring and operating an install — not only in the auth reference; the `impersonation` stamp is described on [events](https://substrat.net/concepts/events.md#impersonation) and [permissions](https://substrat.net/concepts/permissions.md); and the [Scrive connector page](https://substrat.net/connectors/scrive.md) now says how parties are mapped — the account holder is a non-signing author, every named party signs as itself — and names the version that is actually published. Three pages that still described the removed dev header now describe the dev-issuer login ([running locally](https://substrat.net/guide/running-locally.md), [Manyfold](https://substrat.net/verticals/manyfold.md), [create-substrat](https://substrat.net/reference/create-substrat.md)), the [identity concept](https://substrat.net/concepts/identity.md) names which demos are OIDC-only, and the [contract-tests reference](https://substrat.net/reference/contract-tests.md) covers all thirteen suites and the entity-check kit. The reference pages for the [kernel](https://substrat.net/reference/kernel.md), [scope host](https://substrat.net/concepts/scope-host.md), [contracts](https://substrat.net/reference/contracts.md) and [adapter-sqlite](https://substrat.net/reference/adapter-sqlite.md) enumerate the seams that exist today; [the model](https://substrat.net/concepts/model.md) and [API design](https://substrat.net/concepts/api-design.md) describe the three shapes a narrowed permission takes and the concurrency joins above; the [work-order](https://substrat.net/engines/workorder/surface.md) and [booking](https://substrat.net/engines/booking/surface.md) surface pages list the paged reads that actually ship; and the repository README describes the platform as it is rather than as it was in July. ## Released | Package | Span | |---|---| | `@substrat-run/contracts` | 0.88.0 → 0.92.1 | | `@substrat-run/kernel` | 0.88.0 → 0.92.1 | | `@substrat-run/adapter-sqlite` | 0.88.0 → 0.92.1 | | `@substrat-run/adapter-cloudflare` | 0.88.0 → 0.92.1 | | `@substrat-run/vertical-host` | 0.88.0 → 0.92.1 | | `@substrat-run/vertical-auth` | 0.8.1 → 0.9.0 | | `@substrat-run/control-plane-api` | 0.88.0 → 0.92.1 | | `@substrat-run/contract-tests` | 0.88.0 → 0.92.1 | | `@substrat-run/model-emit` | 0.8.0 → 0.8.7 | | `@substrat-run/boundary-lint` | 0.2.0 | | `@substrat-run/psl` | 0.2.3 | | `@substrat-run/dev-issuer` | 0.1.2 → 0.1.6 | | `@substrat-run/engine-workorder` | 0.8.4 → 0.10.0 | | `@substrat-run/engine-booking` | 0.6.0 → 0.6.5 | | `@substrat-run/engine-invoicing` | 0.9.4 → 0.9.8 | | `@substrat-run/engine-protocol` | 0.11.4 → 0.11.9 | | `@substrat-run/engine-invites` | 0.4.8 → 0.5.2 | | `@substrat-run/engine-absence` | 0.5.0 → 0.5.4 | | `@substrat-run/engine-metering` | 0.3.8 → 0.4.2 | | `@substrat-run/connector-scrive` | 0.13.4 → 0.13.8 | | `@substrat-run/cli` | 0.25.8 → 0.25.13 | | `create-substrat` | 0.8.0 → 0.8.1 | No package is new on npm this week. `@substrat-run/model-providers` — the provider catalogue and rate card behind the model host above — exists in the workspace and reaches the registry with the next release. The work-order engine's minor bump is the four new in-scope functions; booking's is the `now` removal; metering's is the tightened `occurredAt`. --- # 2026 › Week 34, 2026 Source: https://substrat.net/changelog/2026-w34.md A vertical has always declared its entities and operations. This week that declaration stopped being documentation and became the source: the migration journal, the browser client, the route table and the OpenAPI document are now derived from `spec/model.ts`, and each derivation is gated in CI, so a second description of a declared thing can no longer drift unnoticed. Two changes to what module code may do come with it — an engine call inside your transaction now has a boundary of its own, and the wall clock is gone in favour of `ctx.now()`. Reads learned to page through headers, declare their filters and search an index the kernel builds; a permission denial through the host is a 403 rather than a 500; and a local OIDC issuer means the login you exercise all day in development is the one production runs. Every engine now declares its operation surface, and the kit that checks handlers against it found the reference implementation misdeclared. ## The model is what everything else is derived from **Two things change shape, deliberately.** `entityRelations` is an allowlist — an entity may legitimately have more than one parent, and two already did — so the singular `parent` is renamed `parents` and `model.json` carries an array. And `emitTables` moved out of `@substrat-run/contracts` into a new build-time package, `@substrat-run/model-emit`; an import of it from contracts no longer resolves. What the model now declares, and what checks it: - **An entity registry** the manifest's entity names are checked against, with **relations** whose both sides are checked against the engines a vertical composes — a typo in either position is a compile error listing the composed set. Local-to-local edges are derived from the entities' own `parents` and cannot be declared twice. - **The operation surface**, with `defineOperations` learning the composed engines, so an operation can emit about the thing an *engine* owns — a checklist toggle on a protocol instance was inexpressible before, and it took converting a 159-operation production vertical to find that out. - **What a permission checks against.** A bare key was ambiguous in the direction that fails open — `list:manage` at the scope or on *one* list looked identical in the model and only the handler knew. Now: ```ts permission: { key: 'list:manage', entity: 'list', idFrom: 'listId' } ``` `entity` is checked against the declared entities and composed engines, `idFrom` against the operation's own input; `resolved: ''` covers the case where the handler must find the entity. One of the two is required, so a narrowed check cannot silently say nothing. - **`renamedFrom`** — the one declaration a diff genuinely cannot derive. - **Lifecycles.** A `status` enum used to describe its transitions a second time as hand-written guards in operation bodies; booking held the same seven states in two independent literals. `defineLifecycles` declares the machine once — `initial`, per-state `on` (an edge) and `allow` (a precondition that moves nothing), `terminal` — and the kernel evaluates it. Work orders, booking, invoicing, Manyfold and the shop are declared. - **Consumers typed against the engines a vertical composes.** Composing by *event* was three plain strings and an `unknown`, which is how a vertical consumed `protocol.signed` and not `protocol.countersigned`, and every two-party contract stayed pending forever. `consumersFor<[ProtocolEvents]>()` types the payload from the producer's declaration, rejects an event no declared engine emits, and refuses a half-handled completion group naming the missed event. From that model, `@substrat-run/model-emit` derives the **DDL** — `id` as a non-null primary key (a hole every hand-written table had), `key` as `UNIQUE`, `parents` as `REFERENCES`, an enum as `CHECK`, a boolean as `INTEGER`; a composite key is a composite, and a stored bare boolean is refused. Parity tests in Callout and Handlebar assert the emitted journal matches the checked-in one, and against a 55-table production model the emitter agreed on 54. It also derives the **browser client** and the **route table** (`pnpm lint:client`): a vertical's `app/src/api.generated.ts` is standalone TypeScript with no imports, and a schema the printer cannot spell exits 2 naming the field rather than degrading to `any`. The cautionary tale is the Todo demo, whose hand-written client was 91 lines a person maintained by remembering to; when a read became paged and search was added, the client learned about neither, and the app rendered the first twenty items of a list as though that were the list. Nothing was red, because nothing could be. To find what the derivation actually costs, one vertical was built *forward* from a one-line brief rather than retrofitted — the existing demos had all had their models added a month after being written, so every check asked the backward question. Seven platform corrections came out of it that six demos never surfaced, the largest being that module code had no way to create a grant at runtime, so an app where a person shares their own record had no supported mechanism. The concepts page for the model was written and corrected, and the rule that an engine is composed **by call or by event**, and must say which in its header, is on record. ## Engine calls are sub-transactions now Catching an error from an engine call used to be forbidden outright, for a good reason: the call runs inside *your* transaction with no boundary of its own, so a bare `catch` leaves you holding the partial writes its invariants were protecting, and then commits them. Now: ```ts try { await ctx.atomic(() => completeWorkOrder(ctx, { orderId, billable })); } catch { // the engine's rows, events, links, grants and platform intents are all gone; // your own writes survive, and it still commits once } ``` A succeeded `atomic` is still provisional — if the operation later throws, its writes go with it. Sub-transactions nest but must not interleave; starting two concurrently throws. Outside `ctx.atomic`, catching an engine error remains forbidden. Both runtimes were probed before the design was fixed: the Durable Object runtime forbids `SAVEPOINT` outright and insists on its own nested transactions, so the two hosts share a contract and not an implementation. ## Module code lost the wall clock **A new lint rule will flag existing code.** Time comes from `ctx.now()`, and an argless `new Date()` or `Date.now()` in module code is a boundary-lint **R6** violation — the same class of ban as `node:*`. Ninety-five hand-rolled timestamps across the engines and demos were stamping rows the host could not see, while the `instant` brand claimed to be "stamped kernel-side". The value is stable for the whole invocation, so a row and the event announcing it agree about when; both hosts route `emit`'s `occurredAt` through the same value. A host takes a `clock`, the same seam as `fetch`, which is what lets a scenario assert elapsed time with `manualClock` instead of sleeping. Reading a stamped value — `new Date(row.created_at)` — is ordinary data handling and stays allowed; the ban is on *originating* a timestamp. Code that genuinely needs the real clock, a JWT whose `exp` a remote server judges, opts out with a reviewable `boundary-lint-allow R6` block. ## Reads: paging in headers, declared filters, a search index **Paging keeps the body's shape, not its size.** A paged read's body stays what it always was — a bare array or the vertical's own object — and the walk rides in response headers: ```http Link: ; rel="next" X-Total-Count: 340 ``` The first design wrapped the body in `{ entries, nextCursor }`, which renames a live endpoint's contract with no way to soften it; for a bare-array list there is no compatibility window even possible. So a vertical can now page all of its list reads without breaking a single parser — but a client that used to read the whole list now gets the first page, and has to follow `Link` for the rest. That is a change in what comes back, not in how it is shaped, and the Todo client above is what it looks like when nobody follows the header. The total is opt-in. **A list read declares its filter and sort vocabulary.** Twelve reads across four engines and four demos answered with whole tables, and the engines carried some thirty-six hand-written `ORDER BY` clauses none of which a caller could choose — a vertical wanting a different sort had no path but to fork the engine. `paged` is now one of two halves: `over: { entity, sortable, filterable }`, where the kernel composes the `WHERE`, `ORDER BY`, keyset comparison, `LIMIT` and matching `COUNT` from the entity's declared columns and provisions the indexes; or `sortKey`, where the handler composes because it genuinely must — a timeline over the kernel's outbox, a read through a correlated subquery, a portal read that decides visibility per row. An absent `over` reads as intent. **`searchables` is real.** The declaration had been in the contract since the beginning and nothing read it; every search shipped was a client-side `includes` over a fully fetched list, which paging turns into a search of the first page only. Now: ```ts ...manifestEntities(calloutEntities, { searchables: [{ entityType: 'customer', fields: ['name', 'number'] }], }), ``` derives an external-content FTS5 index and its maintenance triggers, appended to the module's own migrations, and `ctx.search(entityType, term, …)` reads it. A field the entity does not have is a compile error, and so is a composite-keyed entity. The Todo demo declares search over item text and adds two search reads. Smaller corrections in the same area: a read's query string is documented in the emitted API and a `GET` stops claiming a request body; `http.method` accepts `PUT`; `mountOperations` types the values a URL carries. ## Every engine declares its operations, and a kit checks the handlers All engines now declare their operation surface, and a conformance kit checks that each handler honours what it declared — the permission key, the check's shape, the input schema. Getting the protocol engine there meant breaking a require cycle: its input schemas moved to their own leaf, a scripted move verified against the previous export surface at seventy names with none dropped. The invoicing engine's declared surface is three reads and no writer, and a test asserts that absence — this engine is composed by event, and a creating operation appearing there would be a second way in past immutable-after-export. Extending the kit to six more packages found the reference implementation misdeclared. Callout's timeline declared `customer:manage` at the scope while its handler checked `workorder:read` on the entity — wrong key *and* wrong shape — so `PERMISSIONS.md` said a technician could not read a timeline that a technician could read every time. Handlebar's timeline had the right key and the wrong shape, which read as the portal's isolation stated backwards. Both now declare `{ key: 'workorder:read', entity: 'workorder', idFrom: 'entityId' }` and wire the kit. That is the outcome that makes a kit worth having: the thing everyone copies was the thing that was wrong. ## Errors from the host are honest **A permission denial through `mountOperations` is now a 403.** Every failure the mount could produce came back as `500 Internal Server Error` — a denial was indistinguishable from a crash, and so was an input that failed to parse. The kernel's own vocabulary is mapped and nothing else is: `PermissionDenied` → 403, a `ZodError` or a non-JSON body → 400, a runtime fault → 502, an `HTTPException` passes through as itself, and **anything else is re-thrown unchanged** so a vertical's domain errors still reach its own `app.onError`. The mount decides the status, never the shape; an app that owns an error envelope keeps writing the body. On the vertical's side, the Todo demo is the first to throw with a code — `not_found`, `precondition_failed` — instead of prose the host had to pattern-match; its routes no longer match on error text at all. ## Local login is the production round-trip Every vertical carried an `x-principal` header: a name the server was told and believed, one environment variable from being a live impersonation bypass, and a fork of the auth path four files deep so that the login exercised all day in development was one no deployment ever ran. `@substrat-run/dev-issuer` replaces it: a real OpenID Connect provider — discovery, JWKS, authorization code with PKCE, a signed ID token, RP-initiated logout — whose only shortcut is that `/authorize` lists names instead of asking for a password. It is stateless (the code is a short-lived JWT), its signing key is checked in and public on purpose, and it is never deployed. `POST /dev/token {sub}` mints a token with no browser for tests and headless verification. **For a scaffolded project this changes behaviour:** the dev header is gone, the `… dev` script starts the issuer, and changing issuer is a change of `OIDC_ISSUER`. ## Scaffold, CLI and the agent's seat **The scaffold a user gets is now run, not trusted.** The template under `create-substrat` was a complete vertical none of whose gates ever ran in CI — it had been broken for four minors before anyone noticed, and was broken again four days after the fix. Two gates close that. Post-release and weekly, a job runs `npm create substrat` at the published version, installs from the registry with no workspace links, and runs the three gates a scaffold ships with. And on every PR the template is compiled against the *workspace*, which is the gate that makes a non-additive engine surface red in its own PR rather than after publish — the paged `listOrders` had reached `npm create substrat` failing all three gates. The template's pins are frozen to what ships, the deployed surface is the one you developed against, a UI the push would never serve is refused at push, a dev server that cannot work refuses to boot, and the design document has one name: `spec/concept.md`. **A new lint rule will flag existing code.** `cloudflare:workers` joins the R2 ban. Its ambient `env` handed module code every binding and secret the script declares — including its own `SCOPE` namespace, which reaches another scope's data where `ctx.sql` cannot — and the layer rules stayed green over exactly that. Sharper for engines than for verticals: it was the invariant-owning layer that could reach every scope of whoever composed it. For an agent working in a scaffolded project: a SessionStart hook announces that this is a Substrat vertical, where the rules and the flow live, and the docs slice matching the kernel version actually installed in `node_modules`, so an upgrade opens by stating the jump rather than letting the agent discover it by being wrong. The skills travel to projects that were never scaffolded — `claude plugin marketplace add https://substrat.net/marketplace.json` — and the plugin scaffolds and then defers to the project's own files rather than carrying a monorepo-shaped flow. Every demo and the template carry a `.claude/launch.json`, emitted from the `substrat.devServers` block and gated by `pnpm lint:launch --check`, so an agent starts the dev servers, opens the app and verifies its own change instead of trusting a green suite that never boots the HTTP layer. ## Connectors: the real document, the right signatory The Scrive connector used to send an attestation sheet — one page naming the template, the parties and a content hash — and ask a counterparty to sign it with BankID. Now the vertical uploads its rendered document onto the protocol instance and names it when binding: ```ts const doc = await attachments.upload({ entity: { entityType: 'protocol', entityId }, … }); await scope.invoke('protocol/bind-document', { instanceId, contentRef, contentHash, documentAttachmentId: doc.id }); ``` The freeze event carries the id and the connector sends those bytes; bind nothing and the attestation sheet goes out unchanged. It is by id, never by search, because the return path lands the *signed* copy on the same instance. **The account holder is the sender, never silently a signatory.** Measured against the testbed: Scrive binds the author party to the API account and overwrites the name and email you send, which the connector had mapped from `signatureKind: 'primary'` — so the account owner was invited to sign every contract, whoever the vertical named, and the fail-closed reconcile could never attribute it. The author is now a non-signing sender. A signature request can carry how a party is reached — an email address, sealed to the connector, never a personal identity number — and a declared connection grant repairs itself without touching the credential; the Scrive catalog entry could not grant `protocol:read`, and can. ## For platform operators - **Tracing is a rate, on TEST.** `VERTICAL_TRACE_SAMPLING` adds a `traces` block to every pushed script; absent (prod, today) nothing changes. The finding behind it: `observability: { enabled: true }` had enabled logs and zero spans, so the fleet was not tracing. - **Declared egress is checked against what a version reached.** `GET /verticals/:slug/egress` joins the platform's `fetch` spans against the version's declared `outbound`, and the console renders the difference in the Outbound column beside **Admit** — an undeclared host shows its origin, and *from a durable object — not enforced* says why it was never refused. Unenforced is not undeclared, and unused is not a fault. - A refusal before egress is no longer reported as the provider's; a store that cannot be minted answers with a diagnosis; a declared per-tenant store now reaches the tenants that already existed when it was declared. - In the console, an auto-admitted version says so and the vouch has a button; a narrow viewport keeps its buttons. - Required per-worker secret keys are marked, so a blank one cannot ship. - **Dependency policy:** anything whose *identity* must be shared with a consumer is a peer dependency, so `zod` is a peer of the published packages rather than a dependency — the `expected a Zod schema` failure was two copies in one tree. The contract-tests package had shipped 130 `import("zod")` references in its `.d.ts` while declaring zod nowhere. ## Also - **Docs**: the site is published as markdown — `llms.txt`, `llms-full.txt` and a twin of every page, emitted from the same sidebar the nav renders from; the rules an agent must not violate have a page and `llms.txt` is oriented around them; one page describes how any agent discovers and works with Substrat; the enforcement story has its own page; five diagrams, with the engine state machines read from the code, reach `llms.txt` too. Every page was refreshed against its source and freshness made mechanical. Convex has its own entry in the comparisons. The design-interview skill now decides the auth seam. - **The decision record**: `docs/` moved into strategy, architecture, engines, rfc and briefs; the decision log became one file per decision with every status verified; three missing engine docs were written and the internal corpus gated; the acceptance benchmark was retired and D-28's dual-emit clause narrowed; D-46's Durable Object limit is recorded as enforcement-only, not invisibility. - **Builder studio**: a model phase with the direction rule made mechanical, a build phase that reads the approved model, eval fixtures that start at the prompt so the interview itself is measured, a token budget, a command deny-list that is asserted rather than executed, and scratch projects kept out of the repo's own gates. ## Released | Package | Span | |---|---| | `@substrat-run/contracts` | 0.66.0 → 0.87.0 | | `@substrat-run/kernel` | 0.66.0 → 0.87.0 | | `@substrat-run/adapter-sqlite` | 0.66.0 → 0.87.0 | | `@substrat-run/adapter-cloudflare` | 0.66.0 → 0.87.0 | | `@substrat-run/control-plane-api` | 0.66.0 → 0.87.0 | | `@substrat-run/contract-tests` | 0.66.0 → 0.87.0 | | `@substrat-run/vertical-host` | 0.66.0 → 0.87.0 | | `@substrat-run/vertical-auth` | 0.8.0 | | `@substrat-run/model-emit` | 0.1.0 → 0.7.3 | | `@substrat-run/dev-issuer` | 0.1.0 → 0.1.1 | | `@substrat-run/boundary-lint` | 0.1.0 → 0.1.2 | | `@substrat-run/cli` | 0.24.1 → 0.25.7 | | `create-substrat` | 0.4.2 → 0.7.3 | | `@substrat-run/engine-workorder` | 0.3.64 → 0.8.3 | | `@substrat-run/engine-invoicing` | 0.5.22 → 0.9.3 | | `@substrat-run/engine-protocol` | 0.6.2 → 0.11.3 | | `@substrat-run/engine-booking` | 0.1.61 → 0.5.3 | | `@substrat-run/engine-invites` | 0.1.11 → 0.4.7 | | `@substrat-run/engine-absence` | 0.1.1 → 0.4.7 | | `@substrat-run/engine-metering` | 0.1.1 → 0.3.7 | | `@substrat-run/connector-scrive` | 0.8.1 → 0.13.3 | `@substrat-run/model-emit` and `@substrat-run/dev-issuer` are new this week. Twenty-one kernel-group minors in one week is the model work landing piece by piece; the shape changes a consumer meets are `parents`, the `emitTables` move, and `zod` as a peer. --- # 2026 › Week 33, 2026 Source: https://substrat.net/changelog/2026-w33.md This week was mostly about the seam between a vertical and the outside world. Connecting a third-party provider became something you do from the dashboard or from your own vertical's screen, the credential is checked against the provider before it is stored, and you can read what the connection did and why a delivery failed — a refused request is no longer retried for two days. A signature request now says how each party authenticates. Two engines joined the catalogue, one for approved absence and one for metered usage, and a hosted vertical now declares which hosts it calls. And the **builder studio** — a chat that interviews you and builds a vertical against the real gates — opened to teams holding an entitlement. ## Integrations you can connect, verify and read Connecting a provider such as Scrive used to mean handing your credential to platform staff out of band, and then trusting it. Now it is a screen: the per-app **Settings → Integrations** tab, or the account-level Integrations page, takes the provider's credential and stores it sealed. A vertical that needs a provider says so in its manifest — ```json "substrat": { "requires": ["scrive"] } ``` — and the dashboard shows it as *required by this app*. That is an offer, not a gate: a signing request dispatched before the connection exists settles as pending and delivers the moment it is connected. A vertical can also take the credential on its own admin screen and return it as a harness effect; the platform seals it and never writes it into the scope's data, so it appears in no export or backup. Rotating a credential keeps the connection's id, and with it every grant the connection holds. And a grant made to a connection is now delivered to every install — including one provisioned *after* the grant, which used to ship without its return path and fail every reconcile. **Connecting means verified, not stored.** The candidate credential is sent to the provider before anything is written. If the provider refuses it, nothing is stored and you see the provider's own words; a refused rotation leaves the live credential untouched. If the provider is unreachable, the credential is stored and reported as unverified — an outage is not a refusal. Every result names which environment it asked, because a production key sent to a testbed fails exactly like a typo. That last fact found a bug in the platform's own deployment: the hosted control plane had been sending production Scrive credentials to Scrive's testbed. It is fixed, and stated explicitly per environment rather than defaulted. Opening a connection shows what it is: the provider account it acts as, the stored credential with identifiers whole and secrets masked to their last four characters, the live grants it holds, and a **Delivery attempts** list — every request the platform sent on its behalf, with status, attempt count, and the provider's error verbatim. The provider's own archive is one switch away, marked to show which documents came from this app. Two things a vertical gets from the same work: `ctx.platformRequests(filter)` is the read half of `ctx.requestPlatform`, so an operation can read the outcome of its own intents instead of querying a private table; and a platform that cannot store secrets answers `503` naming the missing key, not a bare `500`. **One thing changes behaviour.** A provider's refusal — any `4xx` — now fails the request on the first attempt, carrying the provider's message. Before, every error was treated as transient and retried for two days; a production tenant had three signature requests sit at a hundred attempts each on the same permanent error, with nothing to show the user. Outages (`5xx`, `408`, `429` and friends) and network failures are still retried. ## Signature requests choose how a party authenticates **One thing changes behaviour.** A party on a signature request now carries an `authLevel`: `basic` (the provider establishes control of a contact address) or `strong` (a national eID). The default is `basic`. Until now the Scrive connector sent every external party to BankID unconditionally — Scrive's word, not the engine's — and since the request could not satisfy what BankID asked for, every document it ever sent was refused. If you relied on that default, say `authLevel: 'strong'` on the party, or set `defaultAuthMethod: 'se_bankid'` on the connector to keep the old behaviour deliberately. `strong` now actually starts a document. Probing the testbed showed that Scrive checks the personal-number field is *present*, not filled — the signatory completes it during the BankID ceremony — so the sender never needs to know a personnummer, and none reaches the platform. When Scrive refuses a document, every reason is reported at once rather than the first one; an operator no longer fixes one problem to meet the next. One caveat is stated on the connector's page rather than discovered: a party still carries no contact address, so a document with a real counterparty is refused at start (visibly, and journalled), while a document whose only party is the author **starts, reports sent, and invites nobody**. The contact carrier is the next piece of this work. ## Two new engines: absence and metering **`@substrat-run/engine-absence`** is the approved-absence ledger, extracted from the Meridian demo now that a second vertical wants it. Every entry is append-only over an opaque subject — you own the employee directory, the engine never dereferences it — and a balance is a fold over entries as of a date, never a stored counter. Only an approved request can book time; cancelling an approved absence writes a compensating reversal in the same transaction; a per-leave-type floor is enforced at decision time, and a negative floor models advance leave with no code change. `availability()` returns coverage only; weekends and holiday calendars stay yours. A daily expiry schedule is declared by the engine. Meridian's `absence:*` permission keys are the same strings as before; its absence events move to the engine's `absence.*` vocabulary. **`@substrat-run/engine-metering`** is the billable-usage ledger. A meter has a fixed kind and unit; entries are append-only and **idempotent by a key you choose** — replaying the same key with the same quantity returns the existing entry, the same key with a different quantity throws, because that is the bug the key exists to catch. Counters sum signed deltas (a correction is a compensating entry); gauges take the maximum in a window and carry their level across silent ones. Closing a period freezes its lines and sets a horizon nothing can write behind, and emits `metering.period-closed` with *unpriced* lines — your vertical maps meter keys to rates and feeds invoicing, the same way timesheet periods already do. Four permission keys: `metering:read`, `metering:record`, `metering:configure`, `metering:close`. ## Hosted verticals declare the hosts they call **One thing changes behaviour.** A hosted vertical's outbound network policy used to be *none*: every `fetch()` from its worker passed through untouched, under the platform's own account. Now the vertical declares what it calls — ```json "substrat": { "outbound": ["api.example.com", "*.stripe.com"] } ``` — hostnames or `*.`-wildcards, never an apex. The list is enforced per request at the egress seam and every verdict is metered, so an exfiltration attempt is a chart rather than a guess. The default is `[]`, and the CLI always sends the field: a version pushed with the current CLI that makes a direct third-party call it has not declared is refused. Most verticals need nothing — connectors run platform-side, mail rides the platform's sender, and calls to other verticals ride the router. A version pushed with an older CLI has no declaration and runs unenforced but metered until its next push, so least privilege arrives version by version and never as a fleet outage. The policy follows the code that is actually serving, not the version an install is bound to, so a promote never enforces one version's list against another's code. ## The builder studio The studio at `builder.substrat.net` is a chat that builds a vertical. It interviews you one question at a time, writes the concept document you approve, then scaffolds and iterates against the same gates every vertical in the repo runs — typecheck, boundary-lint, the permission diff, the scenario test — and commits every turn. Which skills the model is loaded with is decided by facts about the working tree, never by the model's own claim, and the stepper at the top shows exactly that. A red gate is fed back into the next turn and repaired a bounded number of times; a turn the step ceiling cut off says so instead of reading as finished. You pick the model — Claude, Qwen, Cloudflare Workers AI, or any OpenAI-compatible endpoint — and each row says whose infrastructure it runs on. Token spend is recorded in the studio's own metering ledger (the first consumer of the engine above) and priced on a rate card. Access follows the team, not an email list: a team holding the `builder` entitlement can open the studio, and a trial is a grant with an expiry. The entitlement is granted by platform staff for now. This is early software, and its first real builds found the rough edges a first week does — the file pane now reads from a snapshot without waking the sandbox, the stop button stops, installs are run mechanically rather than hoped for, and a quota error is no longer reported as an invalid key. ## Deploying: monorepos, provenance, and the runtime baseline A vertical that lives inside a monorepo can now be connected: the generated deploy workflow takes the package directory, and so does the CLI — ```sh substrat init --ci github --path apps/helpdesk ``` — with the dashboard's connect form gaining a *Directory in the repo* field and deriving the app's slug from that directory. One connected package per repository, still. Every pushed version now records where it came from: a git repository, commit and branch when pushed from CI, or `cli` from a terminal, shown on each version row in the dashboard. Versions pushed before this stay untagged. **One thing changes behaviour.** The platform's runtime baseline — the `compatibility_date` given to every vertical that declares `runtimeNeeds` — moved from 2025-01-01 to **2026-06-01**, and is now kept within six months of today by a test. Those verticals pick the new date up on their next push, a forward move for all of them. A push whose otherwise-ignored `wrangler.jsonc` pins a date *newer* than the baseline is now refused with the remedy named, rather than silently moving a live worker backwards. Two smaller things. A vertical with genuinely no permission surface can say so — `definePermissions({ modules: [], roles: [] })` — and pass the push gate with a stub `PERMISSIONS.md`, distinguishable from a forgotten declaration. And `@substrat-run/vertical-auth` with its dependency `@substrat-run/oidc-rp` are published, so a scaffolded project's `authenticatedPrincipal` has something to install rather than a pointer to a private package; every package has a README, and the reference section gained pages for vertical-auth, psl and create-substrat. ## Dashboard and console - **The team is in the URL**: `app.substrat.net//…`. Old bookmarks redirect onto your pinned team; a link to another team's page switches you before anything loads. - **Both apps refresh themselves** — on tab focus, and on a slow poll while visible — so an install finishing or a teammate's invite shows up without a reload. Nothing polls while the tab is hidden. - **Back works in the console**: navigation pushes history, so Back returns to the list instead of leaving the site. - In the console, bound scopes get bulk selection — move installs to another lineage or retire them in one pass — and the **Members** view is real: the staff roster with grant and revoke, attributed. **For hosted operators: an entitlement key that could never match.** A vertical pushed from a workspace gets a slug like `t-…/crm`, and the entitlement granted at install was derived from that whole slug — a value the manifest's key format cannot express, so the granted and required keys could never agree. The mismatch fires the first time entitlements are projected onto a scope — a reconcile or re-provision, possibly months after install — and every gated operation is refused. The key is now the slug's last segment, repaired on the next install with no re-push. A denial also says what the tenant *holds* beside what is required, with lapsed grants marked, so a near-miss reads as one. ## The auth-server demo is a first-class install The standalone OIDC issuer demo behaves like any other hosted vertical now. `PUBLIC_ORIGIN` is an optional pin: left blank, the issuer derives its `issuer` and every discovery URL from the hostname each request arrived on, so a custom domain that is not yet routable can no longer be advertised as an issuer that routes nowhere. It implements the platform's export and delete verbs, so retire-with-backup works and an install can be moved between lineages with its data. For any script that never implemented delete, retiring — once a backup has been taken or explicitly declined — now tombstones the directory row and reports the bytes as stranded instead of refusing forever. ## Also - **The integration detail** re-reads the connection row after a probe, so *Test connection* no longer reports success beside the error it just cleared. - **Serving**: a missing asset recovered from a version's archive is refused loudly if its bytes do not hash to the manifest, never served on trust. ## Released | Package | Span | |---|---| | `@substrat-run/contracts` | 0.56.0 → 0.65.0 | | `@substrat-run/kernel` | 0.56.0 → 0.65.0 | | `@substrat-run/adapter-sqlite` | 0.56.0 → 0.65.0 | | `@substrat-run/adapter-cloudflare` | 0.56.0 → 0.65.0 | | `@substrat-run/vertical-host` | 0.56.0 → 0.65.0 | | `@substrat-run/control-plane-api` | 0.56.0 → 0.65.0 | | `@substrat-run/contract-tests` | 0.56.0 → 0.65.0 | | `@substrat-run/cli` | 0.21.0 → 0.24.0 | | `@substrat-run/engine-absence` | 0.1.0 | | `@substrat-run/engine-metering` | 0.1.0 | | `@substrat-run/engine-workorder` | 0.3.54 → 0.3.63 | | `@substrat-run/engine-booking` | 0.1.51 → 0.1.60 | | `@substrat-run/engine-invoicing` | 0.5.12 → 0.5.21 | | `@substrat-run/engine-protocol` | 0.5.14 → 0.6.1 | | `@substrat-run/engine-invites` | 0.1.1 → 0.1.10 | | `@substrat-run/connector-scrive` | 0.3.0 → 0.8.0 | Two packages are new this week: `@substrat-run/engine-absence` and `@substrat-run/engine-metering`, both at their first release. The protocol engine's minor bump is the `authLevel` field above. One of the kernel's is `canStoreSecrets`, which an adapter outside this repo must now answer; everything else in the spans is additive. --- # 2026 › Week 32, 2026 Source: https://substrat.net/changelog/2026-w32.md This week reshaped how a vertical gets from a push to a running environment. A vertical now has one channel, `prod`, and every other environment — a pull-request preview, a frozen per-build copy, a long-lived test environment at your own domain — is a *preview*: a scope with data, bound to a version, which you create from the CLI, the dashboard or a generated workflow. A vertical deployed with `substrat push` gained most of what only a self-hosted one could do before: its declared schedules run, its connectors run end to end, it can send mail, call another vertical's API and ship its static files without inlining them. Attachments got a store, invoice lines got honest provenance, and every list the platform returns is paged the same way — a wire change an older CLI must upgrade for. And if you operate a control plane, a reap now takes a copy first, the directory backs itself up, and a platform fault answers as one. ## One channel, and a preview for everything else **One thing changes behaviour.** The `dev` and `staging` channels are gone; a vertical has exactly one channel, `prod`. Promoting to any other channel now answers `400` and points at previews, and `substrat promote` no longer needs `--channel`. Nothing ever served the retired channels — they were write-only pointers — so a running deployment sees no difference; a script that promoted to `dev` sees a refusal. What replaces them is a preview — a scope with data, bound to a version, at its own URL — and this week made previews good enough to be every non-production environment: ```sh substrat preview create --tag pr-42 # fork prod's data onto the pushed version substrat preview create --tag test --empty # a clean-room scope, no source substrat scope domain --domain test.example.com substrat scope bind --version --snapshot ``` - Re-running a tag renews its 72-hour deadline instead of letting the preview die at the original one; `--ttl none` pins it until you delete it. A preview pushes a prerelease label (`0.3.0-pr-42.3`) and no longer claims a real patch number in your registry. - `--empty` provisions a clean-room preview with no source — a brand-new vertical's first environment, or a test environment with its own seed. - `scope domain` binds a custom domain to any scope you own, so a pinned preview can carry a stable address; `scope bind` pins one scope to one version — a canary, a held-back tenant, a catch-up — where a promote moves the fleet. `--snapshot` forks the data first when the bind crosses a migration. - A listed vertical's builder can now preview pending code. Admission gates what reaches an install, not whether you may run your own code against your own data. In the dashboard, a vertical gained a **Previews & environments** panel and an app a **Test environment** card: one click provisions a pinned, clean-room scope that tracks production — every promote advances it, exactly like a real install — and attaching a domain shows the DNS records to publish. `substrat init --ci github` writes the same workflow the dashboard's one-click CI setup commits: a sticky preview per pull request, plus an immutable per-build URL when the repository variable `SUBSTRAT_PER_BUILD_PREVIEW` is set, a test scope rebound on every merge when `SUBSTRAT_TEST_SCOPE_ID` is set, and release labels that follow your `package.json` under `--release changesets`. The platform also hears the GitHub App's pull-request webhooks itself: the preview URL is commented on the PR even when the repository's workflow predates it, and the preview is reaped when the PR closes. Three preview bugs found on real pull requests are fixed. A preview served the promoted production build instead of the version it had just pushed. A create retried after a transient failure adopted the half-built leftover and came up empty; it now reaps the leftover and forks again. And a clean-room preview minted a hostname the platform's wildcard certificate does not cover. The generated workflow's comment step also no longer dies on an apostrophe, and a failed preview push now fails the job instead of commenting a wrong URL. ## Every list is paged **One thing changes behaviour.** Every list the control plane returns — tenants, scopes, verticals, versions, hostnames, roles, the admin log — now answers `{ entries, nextCursor }` with a default page of 20 (at most 200), and takes `limit` and `cursor`; the cursor is the last entry's own sort key, and `nextCursor` is `null` when the walk is done. The dashboard's apps, deployments, events, members and domains page the same way. This is a wire change: a CLI published before this week parses those routes as bare arrays and must be upgraded once a control plane carrying it is live. In the SDK, `HostAdmin.list*` with no page still means everything; only HTTP defaults one. ## A hosted vertical runs its schedules, its connectors and its mail Before this week a vertical deployed with `substrat push` — one with no control plane of its own — parsed its declared schedules, granted them, and never ran them; a connector registered on it failed on every delivery; and a password-reset mail minted a link and dropped it. Each has a door now. - **Schedules.** Add a `SWEEPER` store to `substrat.runtimeNeeds` and export the `SweeperDO` the scaffold now ships; the deployment keeps its own roster of scopes and runs every due schedule and delivery retry from its own alarm. A scope provisioned before joins the roster on its next reconcile. - **Connectors.** The platform runs the connector pass on a hosted vertical's behalf. The connection and its sealed credential stay platform-side, and what crosses back into your scope — an operation invocation, a signed PDF, a grant — is checked in the scope's own permission walk like any other caller. Outbound dispatch rides the platform-request queue, and the Scrive webhook terminates on the platform. A Scrive connection on a pushed vertical works end to end. - **Mail.** Declare `substrat.sendsEmail: true` in `package.json`; once an operator grants the vertical `emailSender`, `PlatformRelayEmailTransport` sends through the control plane's relay from the platform's sender. The vertical never holds a mail credential. - **Calling another vertical.** A `fetch` from one hosted vertical to another vertical's public API on the platform used to time out at the edge; it now re-enters the router. No SDK, no code change. - **Static files.** Declare `runtimeNeeds.assets` — a built directory and how to route paths against it — and the platform uploads the files to the edge's own asset store, served without invoking your worker and versioned with the code. This retires the base64-inlined bundle every demo carried (about 4 MB each for Meridian and Manyfold). A programmatic `assets.binding` is refused at push time rather than deployed as an undefined binding. And the `/internal/*` contract every hosted vertical must serve — thirteen routes, hand-copied into each worker and already drifting — is one call now: ```ts mountPlatformSurface(app, { host, onProvision, resolveOwner, onConfigure }) ``` from the new `@substrat-run/vertical-host` package. You supply the hooks that are yours; the package owns the rest and the error envelope. Meridian, Manyfold and the scaffold use it, and a vertical that never mounts it fails to provision on first deploy — louder than a lint. ## Attachments have a home `attachmentTargets` had been declared by the manifest and every engine and consumed by nothing. Now declare a `blobStoreNeed` in `runtimeNeeds.blobStores` and the platform mints one bucket per tenant — a need it provisions, never a binding you carry. `attachments(principal, tenant, scope)` on the scope host gates every read by the target's `readPermission` and every write by an optional `writePermission`, per entity, exactly where `ctx.check` runs. The metadata row lands in the scope database and travels with `scope pull`, restore and rewind; the bytes go straight to the store under a platform-derived key, hashed on upload, so a row can never point at bytes other than the ones it was born with. A connector can land bytes too. The Scrive connector now fetches the sealed, signed PDF once a document closes and attaches it to the protocol instance. That needs one new permission key, `protocol:attach`, held by no role — only by a signing connection's runtime grant. A vertical using the connector grants it to its Scrive connection. ## Invoice lines carry per-line provenance **A migration changes two columns.** `workorder.completed` always required a per-line `sourceType` and `sourceId`, and the invoicing engine stored neither: every consumer wrote the document's provenance into the per-line columns. The engine's second migration adds `document_type` and `document_id` for the delivery (the key each consumer deduplicates on) and frees `source_type` and `source_id` to say what the line *is* — `time` or `material` on the work-order path, `NULL` where a producer supplies none, and `NULL` for every existing row, since it was never captured and is not invented. If your vertical joined an invoice line back to its order through `source_id`, read `document_id` now. Callout did, and its scenario test now asserts the corrected meaning. The engine also consumes a third event, `timesheet.period-closed` (`closeId`, `customer`, `period`, `billable`, `total`), deduplicated on `closeId`: a time-reporting vertical emits it and gets monthly accrual with no migration and no permission. ## The dashboard shows what your app is doing An app's page gained an **Observability** tab — traffic per version, one log stream across all its versions with a version chip on each line, level and message-search filters, and a click to expand the raw event — and an **Audit** tab, the scope's slice of the append-only admin log with before/after detail. Traffic is attributed to the script that actually serves, so an app taking live requests no longer reads "no traffic recorded". A failure behind either panel surfaces with its status and message instead of a blanket "unavailable", and an upstream fault is a `502`, not a `400`. A vertical has a page of its own now, with the full version list, promotion and go-live history, a **Recent failures** panel (below), and a **Remove** action for a private vertical no scope still runs. Dashboard URLs are real paths — `/verticals/`, `/apps//overview` — bookmarkable and refresh-safe. ## When the platform fails, it says so A storage fault inside the platform used to reach a builder's CI as "you sent a bad request". Now a fault the runtime raised — not your request — answers `502` with the provider's reference intact, and the control plane retries a transient blip on its own restore and snapshot calls before giving up. Every 5xx the platform answers leaves a durable row: an operator lists them under **Operations → Failures** in the console, and a builder sees their own vertical's under **Recent failures** on its page, with the reference to hand to support. `substrat push` and `substrat preview` say plainly when an error is a platform-side fault rather than your code. A platform request that cannot converge gives up after about a day with its last real error instead of retrying forever, and a push no longer fails *after* publishing its version because of an unrelated tenant's certificate record. ## For platform operators: a copy before every reap - **A reap takes a backup first.** A scope reap writes a full-fidelity dump to a platform-held store before wiping a byte and records its address on the reap's audit entry — `null` when none was taken. A store that fails aborts the reap with the scope intact; asking for a backup where no store is bound is `501`, never a silent skip. A reap also refuses outright while any hostname is still bound, so a serving scope cannot be reaped by accident — unbind first. - **The directory backs itself up.** One copy a day from the sweep, thirty kept, the restore rehearsed in tests and refused against a directory that still holds tenants unless you say `overwrite: true`. **Settings → Recovery** in the console shows the newest copy's freshness (Current / Late / Stale), how many are held, and a **Back up now** — and renders an unbound store as the alarm it is. There is deliberately no Restore button; the runbook is on the [control-plane page](https://substrat.net/platform/control-plane.md). - **The access log drains** to the backup bucket as NDJSON batches, and its window finally closes; the admin log is never swept. Retention of stored copies is yours to set — `SCOPE_BACKUP_RETENTION_DAYS` and `ACCESS_LOG_RETENTION_DAYS`, unset by default — so nothing is deleted until an operator chooses a window. - **A tenant can be exported whole** — record, scopes, memberships, roles, entitlements, hostnames, connections and every scope's data — in the platform's documented vocabulary, masked unless you ask for `?full=true`. This is the portability and escrow handover. - **A data subject can be erased.** Live spine payloads keyed to the subject are redacted; stored copies are sealed per subject, so destroying one key reaches into every backup already taken. Five limits are documented rather than promised: one subject per event, vertical-owned tables untouched, copies already handed out, the point-in-time recovery window, and a directory restore resurrecting a key. - **Meters.** `GET /meters` returns tenants and effectively active scopes, and entitlements by SKU and plan — computed per call, staff-only. The other two meters in the plan get no number, and the console says why. - **The console keeps you off the wrong row.** Scopes have search, filters, real pages and bulk actions; a serving scope shows a `Serving · N` badge; reaping a still-bound scope asks to unbind first and names the hostnames; **Prune** clears dead forks, stuck provisions and archived installs in one gesture; a stranded provisioning scope can be archived; a vertical's bound scopes are listed with a retire flow; a reaped scope no longer pins its vertical's registry row forever; and every identifier links into Cloudflare's own dashboard — the serving script, the Durable Object namespace, the tenant's database and bucket. ## Demos: sign-in lives at the issuer Callout, Meridian and Manyfold no longer run a credential store. They are OIDC relying parties: login, sign-up, password and reset live at the issuer (`demos/auth-server`), and the vertical only maps the authenticated subject to a scope principal through owner-claim and invites. Meridian's People screen gained "This is me — link my login", which also issues the self-service grant an employee needs to log their own time; a real install was denying it, because only the seed had ever granted it. Meridian also mounts the Scrive webhook, so a signature is recorded within seconds of signing rather than on the next poll. RallyPoint invites through the invites engine at last: the admin skin invites by name and email and lists open invitations, and a player joins from an unauthenticated `?join=` screen where the entered email is the proof. The invites engine declares its `ui` block — the last engine to do so — and the auth seam gains `identify()`, who-are-you as opposed to who-here. ## Also - **Deploying**: `npm create substrat` scaffolds a vertical that is pushable on day one — a worker, the full `/internal` contract, `substrat.permissions` and `substrat.runtimeNeeds` in `package.json`; `substrat push` with neither `runtimeNeeds` nor a `wrangler.jsonc` refuses with the remedy instead of a file-not-found; a vertical that declares no `entitlements` installs holding its own slug instead of nothing; a vertical that provisions tenants for others declares `substrat.provisions` in `package.json` and an operator approves it in the console; and the platform-request hint on a response is set by the adapter after commit, so no route sets it by hand. - **GitHub**: a team can connect several GitHub accounts or organisations; the import card picks a namespace, and connecting a second no longer severs the first. - **Dashboard**: deleting an app unbinds every hostname it had, custom domains included; the catalog no longer offers the built-in Meridian and Manyfold, which are pushed verticals like any other now; an install form no longer asks for auth twice when the identity picker owns it; a crash on every signed-in page load is fixed; and the sidebar's placeholder counts are gone. - **Self-host and local**: one `better-sqlite3`, pinned at 13.0.3 with prebuilt binaries, so `pnpm install` no longer needs `node-gyp`; Node 22 or newer is required. - **Docs**: an [Environments & previews](https://substrat.net/guide/environments-and-previews.md) guide, a *Backup and recovery* section on the control-plane page, a reference for `@substrat-run/vertical-host`, the `substrat` install-spec fields on the deploying guide, and five pages swept of the retired channels. ## Released | Package | Span | |---|---| | `@substrat-run/contracts` | 0.37.1 → 0.55.0 | | `@substrat-run/kernel` | 0.37.1 → 0.55.0 | | `@substrat-run/adapter-sqlite` | 0.37.1 → 0.55.0 | | `@substrat-run/adapter-cloudflare` | 0.37.1 → 0.55.0 | | `@substrat-run/vertical-host` | 0.47.0 → 0.55.0 | | `@substrat-run/control-plane-api` | 0.37.1 → 0.55.0 | | `@substrat-run/contract-tests` | 0.37.1 → 0.55.0 | | `@substrat-run/boundary-lint` | 0.0.6 | | `@substrat-run/engine-workorder` | 0.3.36 → 0.3.53 | | `@substrat-run/engine-booking` | 0.1.33 → 0.1.50 | | `@substrat-run/engine-invoicing` | 0.3.36 → 0.5.11 | | `@substrat-run/engine-protocol` | 0.4.30 → 0.5.13 | | `@substrat-run/engine-invites` | 0.0.35 → 0.1.0 | | `@substrat-run/connector-scrive` | 0.1.28 → 0.2.13 | | `@substrat-run/cli` | 0.13.1 → 0.20.6 | | `create-substrat` | 0.2.0 → 0.4.0 | `@substrat-run/vertical-host` is new this week, first published at 0.47.0 into the lockstep group. The invoicing engine's minor bumps are the migration and the third consumed event above; the protocol engine's is `protocol:attach`; the invites engine's is the `ui` block and `identify()`. --- # 2026 › Week 31, 2026 Source: https://substrat.net/changelog/2026-w31.md The biggest change this week is one you will not see: updating a hosted app no longer moves it onto empty storage. A version update now runs against the data the app already has, takes a bookmark before it migrates, and can be backed out for a day. Around that, a vertical gained three declarations — its permission surface, which now ships inside every version and is required at push; a `schedules` list, so time-triggered rules run under an honest system actor instead of a script signing in as a person; and a way to ask the platform for a privileged action, such as a new site, without an outbound call. Custom domains issue from a single CNAME, every declared surface gets its own URL, and a hosted instance can be configured at install and per scope. Manyfold, the CMS demo, now creates, switches and archives sites from its own screens, and `npm create substrat` scaffolds a working vertical. ## Updates carry your data forward **This changes what a prod promote does.** Every version used to land as its own worker script, and because a Durable Object belongs to its script, rebinding an app to a new version pointed it at empty storage. Updating stranded the data and the first migration re-ran against nothing — which looked like a permissions bug, not data loss. Now each vertical has one stable serving script where every scope's storage lives; promoting to prod re-uploads the promoted bundle onto it, keeps its secrets (no more re-putting every secret per version) and sends only the storage-class delta. A scope provisioned before this week is adopted onto the serving script on its next prod promote — bytes moved first, routing flipped only after the move succeeds, so a failed serve strands nothing. To do it ahead of time: `substrat scope adopt-serving `, or `--vertical ` for a whole install. If a promote's serve fails, `substrat versions`, the dashboard and the console show the serving version trailing the promoted one and say to promote again; a bundle the runtime refuses is reported as `422 deploy rejected` with the whole error, not a truncated `502`. Two safety nets come with it. Before an upgrade migration runs, the scope takes a point-in-time bookmark, committed even when the migration then fails — which is when it is needed. While the bookmark is fresh the dashboard shows a **Back out** card that restores schema and data to it; every write since is discarded, so it is a first-hours tool, and after 24 hours it needs `force`. Versions are badged *code-only* or *schema change* on the Deployments tab, which also now says when a newer version is admitted but not yet in prod, and that promoting your own private vertical is self-serve on the Verticals page. The considered path back is a backup. `substrat scope restore --file ` loads a pulled `.sqlite`, a local scope file or a `.dump.json` into an existing scope in place, migration frontier included, and re-projects the vertical's role definitions afterwards so permissions work on the restored data. The dashboard's Data tab gained **Export & import**: export is a PII-masked dump in the same format the CLI accepts; import replaces the app's data behind a dialog that states the counts, after forking a seven-day safety copy you can back out to. Restoring into a scope that already holds data used to fail with a bare `FOREIGN KEY constraint failed` — found while recovering a hosted scope — and a dump's scope-level grants pointed at the scope they were captured from, so the restore succeeded and every check denied. Both are fixed on both adapters, and the CLI writes dumps in foreign-key order so any loader survives them. ## Your permission surface ships with the version **One thing changes behaviour: `substrat push` now refuses a vertical that declares no permission source.** Declare it once, typed: ```ts export default definePermissions({ modules: MODULES, roles: ROLES, entityGrants: ENTITY_GRANTS }); ``` and point `package.json` at that file via `substrat.permissions`. Push derives the registry from it — keys and descriptions, role templates, entity-grant shapes — and ships it inside the deploy manifest, so the platform holds the content it commits to at promotion rather than a hash of it. The committed `permissions.json` files are gone; `PERMISSIONS.md` is unchanged and still the human checkpoint. The digest behind the "permissions changed" promotion gate was hashing the worker's *bindings*, not any permission content, so it fired on binding changes and missed real ones; it hashes the surface now. A tenant can finally read that surface: the dashboard's app page has a **Permissions** tab listing each key with its description and the roles that hold it, the role templates, and the entity-grant shapes. When an update is available it shows a version-to-version diff — new, removed and re-described keys, and widened roles. Approving a widened role stays a human decision on the Deployments tab. Two things the kernel now records. Every mutation event carries `authorization`: the checks the operation passed and, when an allow came through an entity grant rather than a role, the object it was granted on. Module code can neither supply nor suppress it. And every refused `assertAllowed` lands in a scope-local denial table — actor, permission, node, operation — written after the rollback it is evidence of, so it survives. A `PermissionDenied` you throw by hand is unchanged. ## Ask the platform for privileged work A hosted vertical is sandbox-clean: it has no credential and no outbound channel, so until now it could not do anything only the platform can — create a second scope, archive one, onboard a tenant. Now, after its own permission check, an operation writes an intent: ```ts const requestId = ctx.requestPlatform({ kind: 'provision-sibling', payload }); ``` The intent is durable in your scope, atomic with the operation and stamped with the acting actor. The platform pulls it and executes it with its own authority, knowing the tenant because it read *your* scope — nothing asserted, nothing forged. Four kinds exist: `provision-sibling` (a new scope beside yours, owner seated), `archive-scope`, and `provision-tenant` and `set-entitlements`, which a deployment enables only for verticals it lists as tenant provisioners — the "manager vertical" whose job is to create tenants running a different product, with grants bounded by the SKUs the manager declares. Retries converge: every id is proposed in the payload, so an at-least-once drain never mints twice. Latency is three layers. Tag the response with `x-substrat-platform-request` and the router kicks the control plane to drain that scope within seconds; the periodic sweep is the backstop, and the queue is the reliability. A scope may hold a bounded number of pending intents before the verb refuses. Beside it, a builder can add a sibling scope to an app directly (`POST /tenants/:tenantId/scopes`, inheriting the parent's vertical and jurisdiction), and the dashboard's Data tab gained a scope switcher for apps that span more than one. ## A vertical can schedule its own recurring work A rule whose trigger is the passage of time — a leave that can no longer be approved once it has begun — had an idempotent operation and no caller, so deployments ran it from a script signed in as a human. Now the manifest declares it: ```ts schedules: [{ operation: 'hr/expire-stale-requests', cadence: { everyMinutes: 60 }, permissions: ['absence:approve'] }] ``` The operation runs under a system actor holding exactly the grants the schedule names, projected at provisioning; its events stamp `{ system: }`. The grant is the switch: a scope runs a module's schedules only while it holds that grant, so disabling scheduling for a tenant is a revoke, and the operation fails its own check closed. `PERMISSIONS.md` gains a *Scheduled work* section. On node `startPlatformSweeper` drives it; on Cloudflare, `definePlatformSweeperDO` is the trigger — a self-re-arming Durable Object alarm, because a hosted vertical in a dispatch namespace gets no cron. Passes never overlap and a pass that throws is reported and re-armed. The same sweep now reconciles migrations. Migrations run lazily on wake, so "0 failed" used to mean nobody had woken the stragglers. The sweep wakes every scope behind the migration frontier, retries failures on a backoff, flags a scope after three, and reports `release N: X/Y migrated` — a measured fact — with a staff read at `GET /fleet/migrations`. ## Entitlements express a plan, and you can read them An entitlement was a per-tenant on/off flag. A grant now carries `plan`, `quota` and `expiresAt`, is audited on every effective change, and is PATCH-shaped — omitted fields keep what the row holds, so an idempotent re-grant at provisioning cannot turn a trial perpetual. Read it at request time: ```ts const plan = await ctx.entitlement('manyfold'); // { key, plan, quota, expiresAt } or null ``` Expiry is applied at read, so a non-null result is live. Quota is exposed, not enforced: read the number and enforce your own limit. **This changes behaviour for hosted scopes.** Entitlements are now delivered with provisioning and projected into the scope, and the per-operation gate fails closed against that projection — expiry and revocation enforce at request time, where a hosted scope previously trusted whatever it was told at provision. A scope provisioned earlier keeps trusting upstream until its next re-provision, so nothing live is falsely denied; a grant or revoke after provision reaches a running instance by that same idempotent re-provision. Declare `substrat.entitlements` and `substrat.ownerGrants` in `package.json` so a pushed lineage installs with the same SKU and day-one owner permissions the catalog entry had. ## Configuring a hosted instance **Install-time config.** The install form renders the `envSpec` your vertical declares — secrets as password fields, defaults prefilled, required fields gating submit — and the values ride *with* provisioning, so an instance arrives configured: an issuer with its admin from the first request. Your `/internal/provision` may return a `result` object, shown on the app's Overview with copy buttons; keys that look like credentials are dropped. **Per-scope settings, read correctly.** A setting saved on the Env tab is delivered to the scope's own storage, not to a worker binding, because one serving script backs every install. A vertical that reads `resolveEnvSpec(env)` therefore only ever sees the deployment default and silently ignores what was saved. Use the new overlay: ```ts const cfg = resolveScopedEnvSpec(spec, env, delivered); // delivered > env > default ``` With vertical-auth, `IdentityDO.getScopeConfig(scopeId)` returns `delivered`; Meridian now reads its ordinary keys this way and is the reference. The Env tab reports delivery honestly — a vertical with no `/internal/configure` route is told so, instead of a silent "applies on next deploy". **Identity.** The issuer chosen at install is now visible and editable under Settings → Identity, and a delivery failure says what to do. A vertical declares `provides: ['oidc-issuer']` or `requires: ['oidc-issuer']` in its `substrat` block and the install offers any active instance of a providing vertical as the issuer — no hard-coded slug. Identity links are projected into each scope too, so a hosted vertical resolves `(provider, externalId) → principal` from its own storage with `resolveIdentityLocal` instead of a login map compiled into the bundle: removing a link is offboarding without a deploy, and a rollback cannot resurrect one. **When an install goes wrong.** The install is recorded step by step — `directory → provision → activate → hostname → identity` — and a failed step shows the vertical's verbatim refusal, not `503 Service Unavailable`. Transient errors from a just-woken script are retried; honest refusals are not. An install stuck at *provisioning* gets a **Resume** that re-runs the tail in place, and the apps list reconciles a stale row against the directory. From the CLI, `substrat installs ` lists a workspace's installs and `substrat scope status ` prints one scope's directory truth. ## Custom domains, per-surface URLs, shared login Binding a custom domain now drives certificate issuance through its real lifecycle — `pending → verifying → active`. You publish one record, a CNAME to `cname.substrat.run`, and the certificate issues; `substrat hostnames verify` re-checks, and Settings → Domains shows the records to publish, a **Check again**, and the reason when issuance fails. A domain that is a bare public suffix (`co.uk`, `pages.dev`) is refused, and so is a session cookie scoped to one: the public suffix list is vendored in a new package for it. A vertical with more than one user-facing surface declares them: ```json "substrat": { "surfaces": [{ "name": "app", "label": "App" }, { "name": "eka", "label": "EKA" }] } ``` Every declared surface gets its own platform URL at install (`-..substrat.run`) and, when a later version declares a new one, on update; the Overview lists each and **Visit** becomes a menu. `substrat hostnames bind --surface eka` does it by hand. To share one login across a scope's surfaces, deliver `substrat:auth.cookieDomain` and the session cookie is scoped to the parent domain. An installed pushed vertical used to end *active with no URL* because its prefixed registry id failed the hostname schema; that is fixed and existing rows heal on read. ## Deploying: declared needs, an allowlist, previews **A push is now refused for two things it used to accept.** First, bindings: the sandbox used to reject a short denylist and let everything else through by omission. It is a positive allowlist now — your own Durable Object class, D1, KV, a queue, an R2 bucket, analytics, and secrets are permitted; `service`, `dispatch_namespace`, managed types like `ai`, `browser` and `hyperdrive`, any unrecognised type, and anything named `CONTROL_PLANE` are refused, with a message naming the binding. Second, a first push whose name matches an existing lineage it could be confused with — a platform builtin, a listed vertical, or your own workspace's product — is refused with a 409 before upload; `substrat push --allow-fork` makes it deliberate, and a slug derived from the package name prints a hint to pin `substrat.slug` so a rename cannot fork. Builders keep the substrate vocabulary: `substrat.runtimeNeeds` in `package.json` — entry module, `needsNodeCompat`, a build command, your own `stores` — replaces a hand-authored `wrangler.jsonc`, and the platform pins the compatibility baseline. A vertical that needs one SQL database per tenant declares it under `runtimeNeeds.tenantStores`: the platform mints a D1 per tenant, binds it to the serving script as `__` and hands the handle over at provision — you never supply a `database_id`, which is what proves ownership. Every serving upload re-derives those bindings, so a deploy is structurally unable to drop a tenant's store. Per-PR previews: `substrat preview create` pushes the working tree, forks the tenant's prod data and serves the PR's code on its own `--pr-N` URL; the generated GitHub workflow does it on open, updates on push, comments the URL and reaps on close. Private verticals in the `global` jurisdiction only. The CLI names what it used to swallow: `substrat version`; a control-plane URL missing `/api` says *got HTML, not JSON*; a `SUBSTRAT_SERVICE_TOKEN` shadowing a fresh login warns; a workspace build older than its sources warns; `substrat versions ` finds a workspace-prefixed registration and `--tenant` works over a service token, so every vertical read resolves the same lineage; a config-delivery `501` diagnoses the lineage fork behind it. A scope whose owner grant vanished is repaired with `substrat scope provision `, using your own token. Staff now review `substrat publish` requests from a queue in the console rather than by hand. The deploying guide gained the push-token CI story and the trap that a vertical registers only on its first *successful* push. ## Every operation documented, or the build goes red A vertical serves `GET /openapi.json` and `GET /api/docs` — Scalar, self-hosted, riding the vertical's own session cookie, so a 403 in the playground is the permission system demonstrating itself — and `POST /api/op/{name}` invokes any operation at one URL each. The catalog is built from the same Zod schemas the handlers parse, an import-time parity check throws on an undocumented or ghost operation, and `pnpm lint:api --check` gates the checked-in `openapi.json`, so an API-shape change cannot merge without appearing in a diff. Meridian and Manyfold serve it, the new-vertical skill scaffolds it, and the dashboard's Production card shows each app's spec URL and a link to its docs. ## Demos: Manyfold runs many sites; `npm create substrat` works **Manyfold** is the first consumer of platform intents. An admin creates a site from a **+ New site** control — the request is an intent, the platform provisions the scope, the switcher picks it up — switches between sites, and archives one. The remaining screens from the design handover are built against live data: the field editor and staged model editor, the diff-first migration review, the relationship map, reference pickers, a Markdown editor, delivery preview, the asset library and members with pending invites. Its dev server now authenticates with real sessions like the deployed worker — the `x-principal` header and the `/api/personas` list are gone, which also removes a crash on the deployed app where that list fell through to the SPA. **`npm create substrat my-app`** scaffolds a working bike-repair reference vertical — nine passing tests, typecheck and boundary-lint green against the published packages — plus an instruction layer that Claude Code, Cursor and opencode all read from one source (`AGENTS.md` and `.substrat/playbook.md`), so the build flow travels outside the monorepo. Its docs links point at substrat.net and a missing target directory says so. ## For platform operators: reclaiming storage, deleting a tenant Cloudflare never garbage-collects a Durable Object, so an archived scope kept every byte forever. A scope past `archived` can now be **reaped**: its storage is wiped, the directory row stays as a tombstone, and its slug is released. Manually it is a type-the-slug action in the console; automatically it runs on the sweep after `SCOPE_RETENTION_DAYS`, which is opt-in and unset by default because it cannot be undone. Tenant delete is now a real lifecycle: `deleting` is a reversible pause during a grace window, `reaped` is terminal after `TENANT_RETENTION_DAYS` or a staff "reap now"; the admin log is never swept. Tenant display names flow to the shared directory, so the CLI's workspace picker shows organizations rather than placeholder ids, and an interactive `substrat push` finds its workspace on the first try. ## Also - **Skills**: the `substrat` skill now lands a design document you approve before any code, then hands off to `new-vertical` to build it. Each demo's declarative surface lives in its own `manifest.ts` beside `migrations.ts` and `module.ts`, and new verticals are scaffolded that way. - **Dashboard**: an app's page is five tabs — Overview, Data, Deployments, Previews, Settings — with old links still landing; the push panel shows the flagless `npx @substrat-run/cli push`; the surface picker is always a menu. - **Docs**: a drift audit realigned the deploying guide, the dashboard and router pages and the concept pages with the code, and added pages for Manyfold, the control-plane API and boundary-lint; the architecture page has layer and runtime-topology diagrams; a design note maps the platform-neutral surface onto Kubernetes; design docs for platform intents and multi-site Manyfold, and a correction to the router-caching reasoning, are on record. ## Released | Package | Span | |---|---| | `@substrat-run/contracts` | 0.18.0 → 0.37.0 | | `@substrat-run/kernel` | 0.18.0 → 0.37.0 | | `@substrat-run/adapter-sqlite` | 0.18.0 → 0.37.0 | | `@substrat-run/adapter-cloudflare` | 0.18.0 → 0.37.0 | | `@substrat-run/control-plane-api` | 0.18.0 → 0.37.0 | | `@substrat-run/contract-tests` | 0.18.0 → 0.37.0 | | `@substrat-run/psl` | 0.2.0 | | `@substrat-run/engine-workorder` | 0.3.16 → 0.3.35 | | `@substrat-run/engine-invoicing` | 0.3.16 → 0.3.35 | | `@substrat-run/engine-protocol` | 0.4.10 → 0.4.29 | | `@substrat-run/engine-booking` | 0.1.13 → 0.1.32 | | `@substrat-run/engine-invites` | 0.0.15 → 0.0.34 | | `@substrat-run/connector-scrive` | 0.1.8 → 0.1.27 | | `@substrat-run/cli` | 0.5.2 → 0.13.0 | | `create-substrat` | 0.1.0 → 0.1.1 | `@substrat-run/psl` is new this week — the public suffix list behind the domain and cookie guards. The kernel group's nineteen minor bumps are the additive surfaces above; the one contract that changed shape is the per-tenant store's `query`/`exec`, now async, before any consumer existed. --- # 2026 › Week 30, 2026 Source: https://substrat.net/changelog/2026-w30.md This is the week Substrat became something you deploy to rather than something you run. `substrat push` uploads a built vertical from a laptop or a CI job, the platform holds the only Cloudflare credential, and a private vertical goes all the way to production without anyone on the Substrat side in the loop. Around it: a tenant-facing dashboard where a customer signs up, names a team, installs an app from a marketplace and invites colleagues; test copies and snapshots, so a schema-changing upgrade has a rollback point; a hosted vertical that evaluates permissions from its own storage and needs no line to the control plane on the request path; sign-in it does not have to write; and a first connector, Scrive, that turns a signature request into a real e-signing document and records the signature back. Two new demos — Manyfold, a headless CMS, and an OIDC issuer — join Meridian, which became installable from the marketplace. ## Deploy a vertical with one command Until this week a vertical reached Cloudflare because someone ran `wrangler` with the platform's own token. Now it reaches it through the platform: ```sh npm install -g @substrat-run/cli substrat login # opens the browser; you sign in as yourself cd my-vertical && substrat push ``` `login` runs a loopback OAuth flow through the control plane, so you are the actor on every push in the audit log; `login --token` stores a service credential for CI instead. `push` builds the vertical with `wrangler --dry-run`, derives the deploy manifest, and uploads the bundle to a dispatch namespace. The slug and display name come from a `"substrat": { "slug", "name" }` block in `package.json` or from the package name; the version defaults to the registry's latest, patch-bumped, so nothing is retyped and a stale number cannot slip in. `substrat versions ` lists what you have pushed with each version's admission and channels; `substrat promote --channel dev|staging --version ` moves a channel; `substrat whoami` says who and which workspace. The CLI is published under Apache-2.0 — it holds no platform code, only a build and a POST. **Two things change behaviour.** A vertical you push is stored as `/` — the bare `--slug` you type is prefixed with your workspace's handle server-side, so every team can own a `helpdesk`; hostnames never carry the prefix. And because the *first* push of a slug claims it for whichever workspace resolved, `push` no longer falls back to the machine-wide login default: it resolves `--tenant`, then `SUBSTRAT_TENANT`, then a `"substrat": { "tenant" }` pin in `package.json`. The first interactive push offers to write that pin; a non-interactive push with none exits 1 rather than guess. You no longer author a `wrangler.jsonc` either. A vertical declares `substrat.runtimeNeeds` in `package.json` — `entry`, `needsNodeCompat`, an optional pre-bundle `build`, and its own `stores` — and `push` derives the runtime config from that at the platform's pinned baseline, feeds it to the dry run and discards it. A hand-written `wrangler.jsonc` still works as the expert path; when both exist, `runtimeNeeds` wins and says so. Your own Durable Object stores and D1 bindings ride through; a bundle that declares a `CONTROL_PLANE` binding, a cross-script Durable Object or a service binding is refused with a 403 — that is the sandbox contract, and it is what makes a customer's code safe to run beside everyone else's. `nodejs_compat` and the platform's two shared secrets are forwarded and injected for you; a bundle the runtime rejects comes back as `502` with the runtime's own message rather than an anonymous 500. ### Private verticals are yours end to end **This is the decision of the week.** The old model gated *every* prod promotion on a Substrat staff member, which protected nothing for a vertical only its owner will ever install and left builders parked at "Admission: pending". Now the human gate moves to where the audience widens. A **private** vertical — owned by your workspace, not listed in the marketplace — lands admitted when pushed, every channel including `prod` is yours to promote, and a prod promote re-points your live installs to the new version (snapshotting first if the migrations changed). Every promotion is appended to the channel's history, which the dashboard's **Go-live history** panel rolls back from in one click. The loop from a merge to main is: ```sh substrat push --promote prod ``` with `--ack-permissions` and `--ack-migrations` for a version whose permission or migration surface differs from the one it replaces. Listing a vertical in the marketplace is where staff review returns: a **listed** vertical's pushes land pending again and its prod promotion is staff-only, because from then on other tenants run it. Shared verticals never auto-rebind anyone's install. ### Deploy from GitHub in one click On the dashboard's **Verticals** page, connect the GitHub App, pick a repository and a branch, and **Set up deployment** writes a deploy credential as a repo Actions secret and commits `.github/workflows/substrat-deploy.yml` to that branch; the first run is the first push. The credential is a **tenant-scoped push token** — it travels in the same header the CLI uses, verifies into a builder principal confined to your own workspace's verticals, and cannot promote to prod or admit anything. Put that in a customer repo, never the platform-wide service token. The generated workflow installs dependencies from the lockfile it finds and runs Node 22, because the first real repo it was tried on failed on a bare checkout and — since a vertical registers only on its first *successful* push — the failure was invisible from the dashboard. A workflow committed before that fix needs the install step added by hand. ## Your tenants get a dashboard `app.substrat.net` is the customer's own surface — separate from the operator console, which stays on `console.substrat.net` so a platform-admin session cookie can never reach a tenant app. First sign-in bootstraps a tenant of your own and asks you to name your team; from there you install apps, invite people and manage what you build. **Teams.** A user belongs to as many teams as they are invited to (a team is a tenant; the sidebar switcher is a cookie re-verified against real memberships). Invites are hashed, accept-required and emailed — a new `adapter-email` host package sends through Cloudflare Email Service, with a mock for dev. Delivery is best-effort: a committed invite is never rolled back if the mail fails, and the accept link is returned for manual sharing. An invited person who signs up from the link lands in the team, with the address prefilled; a signed-in member who opens an invite meant for someone else sees "this invite is for X" rather than silently switching teams. Members can leave; owners can **delete the organization** — type its name, every app is deprovisioned, the tenant becomes a tombstone. **One thing changes behaviour:** first login no longer creates a team from your email domain — it lands on a "name your team" step. **Apps and verticals are different pages.** **Create app** offers the marketplace (published verticals) and **Your verticals** (what your team pushed, installable once a version is on prod). The Verticals page owns the supply side: your pushed verticals, their versions and channels, GitHub import and the CI scaffold. A new app binds `-.global.substrat.run`; the form previews exactly that. **What actually runs.** An installed app is *pinned* to the version it was installed at. For a marketplace vertical, promoting a new version to prod moves the channel pointer and nothing else — the one exception is the private vertical above, whose owner's own installs follow its prod promote — so the per-app **Deployments** tab now shows the running version with a `running` pill and an **Update to latest** button that rebinds this one install, and the Overview shows the real version with an "update available" badge. A failed install is marked `failed` with the vertical's verbatim reason on a real **Activity** trail (`created → active → failed → deleted`), and **Retry** tears down the attempt and provisions again. Delete really deprovisions and releases the name, so delete-then-recreate with the same name works. **Configuring an install.** The **Env** tab renders the `envSpec` your vertical declares in its manifest — grouped fields, placeholders, secrets write-only — and *delivers* the values to the running instance rather than only recording them. An **Identity** section on the install form picks the app's identity source: the built-in per-tenant store, one of your team's Auth Server apps, or any external OIDC issuer (more under sign-in below). The **Data** tab browses the app's tables and, new this week, gives you a read-only SQL console. ### A read-only SQL console Beside the table browser, type any `SELECT` (or `WITH`, `VALUES`, `EXPLAIN`) and run it with ⌘⏎. Read-only is enforced twice: a token scan that rejects a second statement and any write or DDL verb anywhere in the text — `WITH … INSERT INTO` is valid SQLite, so the first keyword alone proves nothing — and an adapter backstop (`sqlite3_stmt_readonly` on the pure adapter; on the Durable Object the query runs inside a transaction that always rolls back). Results are capped at 200 rows with a `truncated` flag, and the SQL you ran is what the access log records. Row editing is out of scope forever: a write here would bypass the event log. ## Take a test copy before you upgrade Migrations are forward-only, so "rollback" cannot mean running them backwards. It means keeping a copy. A scope can now be **exported** (a complete dump, spine included) and **imported** into a fresh scope at the same migration frontier, bound to the same version — a running copy, not loose rows, with its provenance (`forkedFrom`, `forkedAt`) on the record. On top of that: - **Snapshot before update.** The dashboard's **Update to latest** has a "Snapshot data first" checkbox, on by default. A code-only rebind ignores it; a migration-crossing one takes the copy first, so a bad upgrade has somewhere to go back to. - **Test copies with a TTL.** App → **Snapshots** → "Create test copy" with 1, 7 or 30 days, or keep until deleted. A copy gets its own hostname, `--s.global.substrat.run`, active immediately, behind the app's own sign-in with the source's users as of the fork. It receives no traffic of its own and auto-deletes on expiry; a control-plane sweep every fifteen minutes reaps what has expired. Copies are dead ends — nothing merges back. - **Nothing leaves the deployment.** A snapshot copies between sibling Durable Objects inside the vertical's own deployment; no scope bytes reach the control plane. A hosted vertical takes part by exposing `POST /internal/snapshot` and `POST /internal/delete-scope` (Callout, Meridian and Manyfold do). The one path that deliberately moves data out is for staff: `substrat scope pull ` writes `.substrat/__.sqlite`, a real SQLite file in exactly the pure adapter's shape, so a local harness runs the identical vertical against production-shaped data. It is jurisdiction-gated (anything pinned tighter than `global` is refused), access-logged, and **masked by default** — PII-named columns become `[masked]` and JSON payloads are swept by key; `--full` is the break-glass and prints a warning. ## A hosted vertical stands on its own **This changes what a hosted vertical is.** Every permission check used to be a call to one global directory object, which meant every scope on the platform serialised through it and a customer's untrusted code would have needed a binding to it. Now the control plane is a *write-time* authority: it projects role definitions and tenant memberships into each scope's own storage when they change, and the scope evaluates permissions locally — fail-closed, so an empty projection denies. Scope-level grants are immediate; a tenant-level role change becomes visible across that tenant's scopes eventually, with a reconciliation sweep behind it. On Cloudflare this is `scopeLocalPermissions: true` on the host; a host built with no control plane at all is now valid, and the entitlement check on the request path passes because the SKU was enforced at provisioning. Callout is the reference for the resulting shape, and Meridian and Manyfold follow it: the worker binds only its own `SCOPE` Durable Object (plus its own D1), trusts the tenant and scope the router asserts, serves its SPA from bytes bundled into the worker at build time (a pushed vertical cannot upload static assets), and exposes one platform-facing route, `POST /internal/provision`, that the control plane calls to create an instance. That route authenticates on `PLATFORM_SECRET` alone and **fails closed when the secret is unset** — the opposite of the router secret, where unset means "no router". Provisioning is two-phase: a scope is created as `provisioning`, the vertical is called, and only its confirmation activates it; `getScope` refuses anything not active. A harness that provisions must now call `activateScope`, and the directory row is written *before* the vertical call, so a stuck row is a work item a sweep can find rather than an orphaned object nobody can see. **On Cloudflare a permission denial now returns 403**, as it does on node; the error was being rebuilt across the Durable Object boundary and degrading to 400 on every deployed vertical. Two adapter bugs found by doing this for real: the Cloudflare adapter split SQL on `;` naively, so a semicolon inside a comment or string literal truncated the statement — green on every SQLite test, failing only on workerd, first in a Meridian migration and then in the kernel's own DDL, where every scope failed closed at construction. Both paths now use a SQL-aware splitter, and the shared contract-test migration contains every hard case. And the router reused a Durable Object stub across requests, so after each cold start the first request succeeded and every later one returned Cloudflare's 1101; stubs are rebuilt per request, and the test fake now enforces workerd's ownership rule. ## Connect an e-signature provider The first connector ships: `@substrat-run/connector-scrive` 0.1.0 turns the protocol engine's `protocol.signatures-requested` into a Scrive document, polls for completion and records the signature back into the scope. What it runs on is general: - **Connections.** A per-tenant provider credential lives in a connection store keyed on `(tenant, vertical, provider)`, sealed by a `SecretBox` (AES-256-GCM with a key id, so rotation is a sweep). A host with no `SecretBox` refuses to store a credential rather than store it in the clear. Revoking a connection destroys the sealed blob, its connector state and its grants in one act. - **Connectors.** `registerConnector(id, eventType, handler)` rides the same post-commit dispatch as executors. The handler gets the tenant, scope and vertical ambiently — there is no parameter through which to ask for another vertical's credential — and `connection(provider)` returns the opened credential with a **bound `fetch`** that lands health (`lastOkAt`, `lastError`) on that connection row. Module code still cannot reach any of this; a connector is host code. - **A connection is a subject.** To write back, the connection opens the scope *as itself* and holds an ordinary permission grant — `protocol:record-signature` — checked on the same path as any principal, with the event actor stamped `{ connection }`. There is no allow-list and no bypass. **Breaking for a custom permission checker:** `check` now takes a `CheckSubject` rather than a `PrincipalId`; `asPrincipal(id)` covers the common case. - **Deliveries retry, back off and dead-letter.** A throwing executor used to escape after commit, so the caller was told the operation failed when the work order existed, and one poison event wedged every executor behind it. **This changes semantics:** an operation now succeeds even if its outbound effect has not happened yet — the delivery is journaled, backed off and retried, and after exhausting its attempts it is dead-lettered with the error kept, readable through `executorDeadLetters`. - **Connector state lives in the directory.** Writing the provider's document id back into the scope deadlocks — a connector runs inside the scope's own post-commit dispatch — so `putConnectorState`/`getConnectorState` keep a ledger beside the connection. That is what makes a retried `signatures-requested` skip rather than create a second legal document. - **Polling is the floor.** `startPlatformSweeper` drains deliveries and reconciles live connections on a timer (node) or from a cron / alarm (Cloudflare). Nothing polls unless your deployment schedules it; dispatch works without it, signatures are never recorded back without it. The connector was corrected against Scrive's real testbed on first contact (OAuth1 signing, not a bearer token; multipart upload; no `status` on create), and its mock now enforces the real encodings. The live BankID round-trip is still unverified — it is disabled on the testbed — and the reconcile fails closed on any shape mismatch, which is why the package stays `0.x`. ### Protocols can be signed asynchronously For all of this to have something to sign, the protocol engine gained a **signature request**: `open → requestSignatures → pending_signature`, then `signed` once every party has signed, with `cancelSignatureRequests` back to `open`; in-app `signProtocol` still goes straight to `signed`. **This changes behaviour:** `pending_signature` is frozen — no fill, no rebind — where before an instance out at a provider stayed writable and its content could drift from what was hashed. A signatory may be a principal or an `external` party with no account. Content has a `kind`: `checklist` as before, or `document`, where the vertical owns the rows and binds `(contentRef, contentHash)` with a required `hashRecipe` so an auditor can reproduce it; the engine proves the signature was made over that hash and that it has not moved. Three new permission keys — `protocol:bind`, `protocol:request-signature`, `protocol:record-signature` — are held by no role in any demo; the last speaks for a provider, not a person, and granting it is how a deployment says what it trusts to speak for Scrive. The migration rebuilds the three protocol tables and backfills the frozen hash; every existing signature still verifies. ## Sign-in you do not have to write The platform apps moved to OIDC: the dashboard and console are relying parties against the platform issuer through a new shared `oidc-rp` package — authorization code with PKCE, JWKS verification, a signed session cookie, no auth database and no `node:*`. Signed-out visits go straight to the issuer now (the local sign-in card survives only as the retry screen), sign-out is always federated, and `substrat login --fresh` forces an account picker — before this, switching accounts was impossible because the issuer's SSO cookie silently signed the old user back in. For a vertical, `@substrat-run/vertical-auth` gives the app one contract, an `AuthProvider`, and two implementations: an OIDC bearer provider that verifies a JWT against an issuer's JWKS (Supabase, Auth0, AuthHero, Keycloak — any issuer), and an `IdentityDO`, one Durable Object per tenant running a full credential store with a `sub → principal` directory. No shared `AUTH_DB` D1 across every install of a worker, which was wrong for hosting: one global email namespace, one principal binding. The owner seat is seeded at provisioning and the first login claims it — **trust on first use with an explicit setup screen**, after which self-service sign-up returns 403 and people join by invite: an admin creates a one-time link at a role, the invitee opens `?invite=`, creates their login and arrives already holding the role's permissions, resolved from the scope's own storage. The identity source is now chosen at install. The dashboard's **Identity** section offers the built-in store, one of your team's **Auth Server** apps, or an external issuer. Picking a team Auth Server registers the app there automatically (dynamic client registration against the app's real hostname) and delivers the resulting config before the app goes active; a failure marks the install failed with the reason. The Auth Server is a new demo — an OIDC issuer with discovery, JWKS, password reset and its own admin — and it is multi-instance: one issuer per install, each with its own users and signing keys. Its database shows in the Data tab with credential columns redacted inside the Durable Object before anything crosses out. ## Demos **Meridian** (HR) became a hosted app. It installs from the marketplace, and a fresh empty instance is usable from zero: the installer becomes `hr-admin` and lands on an **Admin** section with a setup checklist — define leave types (with Swedish and Spanish statutory presets), add people, add projects — plus the per-period payroll export. The employment contract is no longer a checkbox labelled "signed": the articles (role, salary, occupancy, start date, notice) live in an append-only terms table, `hr/issue-employment-contract` binds them as a document and requests two signatures — the employer as a principal, the new hire as an external party who has no login on the day they sign — and `hr/verify-contract` re-derives the hash from Meridian's own rows. Meridian is also the reference wiring for the Scrive connector: the worker registers it, opens a connection holding only `protocol:record-signature`, and runs the sweeper, so a completed signature is recorded unattended. **Manyfold** is new: a multi-scope headless CMS where a site is a scope, so one install serves many sites. Content moves `draft → in_review → approved → published` without skipping, revisions are append-only, publishing freezes the body under a content hash, and the delivery surface resolves references — a draft or archived target comes back explicitly `unresolved`. Content types are data authored in a model builder, and each change compiles to a reviewable migration rather than a live `ALTER`. It runs at `manyfold.global.substrat.run` with its own `IdentityDO`, owner claim and invites, and it is the second vertical on the CP-less pattern. **Callout** is the first sandbox-clean vertical to go through `substrat push`, and the pattern the others copy. Its unauthenticated `/api/seed` — which created tenants and known demo logins on the deployed hostname — is gone. ## For platform operators - **Production is on its own account.** The control plane runs at `console.substrat.net`, the router beside it; both deploy to a TEST environment on every push to main and to prod when a release publishes. Deploy scripts are `cf:deploy` everywhere (the old `deploy` name collided with pnpm's built-in), and a control-plane deploy now **refuses to run while the target D1 has pending migrations** — the first production 500s were an unapplied migration. The `*.global.substrat.run` wildcard is attached to the router, so tenant hostnames resolve live, and `tools/set-platform-secrets` generates each shared secret once and sets it under the right name on every service that shares it — a `SERVICE_TOKEN` / `CP_SERVICE_TOKEN` name mismatch had cost a long detour. - **Jurisdiction is `eu`, `us` or `global`, never null,** and defaults to `global`, which is the honest name for what every scope already is. `eu` and `us` are refused with a 400 at the control-plane boundary until the mechanism that enforces them exists; the console shows them disabled. Residency lives in the hostname (`*.eu.substrat.run`), not in a column. - **Secrets a pushed vertical needs are injected at push** — `PLATFORM_SECRET` and `ROUTER_SECRET` — because `wrangler secret put` cannot target a dispatch-namespace script. The control plane needs `CF_API_TOKEN`, `CF_ACCOUNT_ID` and a `DISPATCH` binding; the dashboard needs the GitHub App secrets, a `SECRET_BOX_KEY` and `PUSH_TOKEN_SECRET`; invite mail needs Email Sending onboarded on a sending subdomain. - **Registry hygiene.** `DELETE /verticals/:slug` removes a vertical, refused while any scope is bound; **block new installs** hides a vertical from the catalog and refuses new instances while existing ones keep serving; deleting an app archives its scope and releases the slug and hostname; and `tools/cleanup-orphans.mjs` (dry-run by default) reclaims hostname rows, expired forks and dispatch scripts that a failed step left behind. - **Observability.** The router stamps every resolved request with its tenant into Analytics Engine and pushed verticals are uploaded with observability on, so the console's fleet view shows invocations, error rate and CPU percentiles per service with recent logs, and a builder's Verticals page has a Traffic panel for their own deployments. The API token needs Account Analytics: Read and Workers Observability: Read. - The Data view reads a scope's *bound* version's deployment rather than the prod channel's — an install behind prod used to show empty tables. ## Also - **Docs**: a full refresh caught the site up — a *Deploying a vertical* guide, a **Platform** section (control plane, console, router, dashboard), pages for Callout, Handlebar and the shop demo, a *Snapshots* concept page and the Connectors section with its catalog; the permissions and identity concepts describe projection-on-write and OIDC as built. Every published package's npm page now links to its own docs page. - **Tooling**: the repo typechecks on TypeScript 7, about ten times faster per package; contributors should point their editor's TS server at it. - **Design notes on record**: deploy orchestration, the untrusted-builder trust model and the sandbox contract, the builder plane, scope-local permissions, previews and snapshots, the marketplace's push/publish tiers, observability, detachable vertical auth, and the CMS content model. ## Released | Package | Span | |---|---| | `@substrat-run/contracts` | 0.7.0 → 0.16.0 | | `@substrat-run/kernel` | 0.7.0 → 0.16.0 | | `@substrat-run/adapter-sqlite` | 0.7.0 → 0.16.0 | | `@substrat-run/adapter-cloudflare` | 0.7.0 → 0.16.0 | | `@substrat-run/control-plane-api` | 0.7.0 → 0.16.0 | | `@substrat-run/contract-tests` | 0.7.0 → 0.16.0 | | `@substrat-run/engine-workorder` | 0.3.4 → 0.3.14 | | `@substrat-run/engine-invoicing` | 0.3.4 → 0.3.14 | | `@substrat-run/engine-protocol` | 0.3.5 → 0.4.8 | | `@substrat-run/engine-booking` | 0.1.1 → 0.1.11 | | `@substrat-run/engine-invites` | 0.0.3 → 0.0.13 | | `@substrat-run/connector-scrive` | 0.1.0 → 0.1.6 | | `@substrat-run/cli` | 0.1.0 → 0.5.0 | | `@substrat-run/boundary-lint` | 0.0.2 → 0.0.5 | | `create-substrat` | 0.0.1 | `@substrat-run/cli` and `@substrat-run/connector-scrive` are new to npm this week, and `create-substrat` is reserved. The kernel group's nine minor bumps are the additive surfaces above; the one shape change is the permission checker's `CheckSubject`, and the protocol engine's minor is the signature-request migration. --- # 2026 › Week 29, 2026 Source: https://substrat.net/changelog/2026-w29.md This is the first week the platform exists under its own name. On Monday the repository was renamed from its working title to Substrat, given a licence and an npm scope; on Tuesday the first packages were published. By Sunday there were five engines instead of two, a kernel that runs a vertical's guards inside its own transactions, a shared control plane with a staff console deployed on Cloudflare, a registry that separates *pushing* a version from *serving* it, and a router that turns a hostname into a tenant and a scope. A vertical was built from an empty directory against the published packages alone, and the linter that enforces the layer rules ships as a package so that vertical can run it too. Most of what a builder types today — `registerModule`, `ctx.link`, the tuple permission checker, manifest guards — landed this week. ## Substrat, on npm The npm scope is `@substrat-run`. The kernel, adapters, contract tests and engines are AGPL-3.0 with a commercial licence; `@substrat-run/contracts` — the interface a vertical imports — is Apache-2.0, so copyleft can never capture an application built on it; and the demo verticals are Apache-2.0 too, because a template is copied rather than imported. Publishing is tokenless: the release workflow signs in to npm with OIDC trusted publishing and holds no secret. **One thing changes behaviour for anyone who installed early.** All packages moved to Zod 4, and `@substrat-run/contracts` re-exports `z`. Import it from there and never install `zod` yourself — the documented `pnpm add zod` gave a fresh project Zod 4 while the packages pinned Zod 3, and composing `entityRef` or `money` into your own `z.object` failed at runtime with "expected a Zod schema". That was found by building a vertical from scratch outside the monorepo, which is now a thing that works: an empty directory, the published packages and the `substrat` skill produced a passing vertical in a quarter of an hour. `npm create substrat` is reserved and points at the docs; it does not scaffold yet. The layer rules ship with it. `npx @substrat-run/boundary-lint` runs the same checks the repository runs — star topology, `ctx.sql` only, no network, no `_substrat_*` writes, and **no SQL against another module's tables** — with table ownership read from each engine's shipped migrations rather than from a workspace. Before this it passed green on any standalone project because it had no owner map at all. It exits 2 when it cannot do its job; a vertical that composes no engine passes, one that declares an engine it cannot resolve does not. ## What a module can do The kernel primitives a vertical is written against arrived together. A module registers with a manifest that bundles its migrations, operations and consumers; migrations run per scope, lazily on wake, and are crash-safe. Each operation runs in one transaction — its writes and the events it emits commit or roll back together. `ctx.link(child, parent)` records an entity relation the permission checker walks, and the built-in checker resolves a four-rule tuple algebra (roles, org membership, entity walks, expiring grants) with a proof path for every yes. Events are dispatched at least once through a delivery journal; a consumer that throws dead-letters its delivery. A vertical can declare a compliance gate in its manifest rather than in code: ```ts guards: [{ before: 'bike-shop/close-repair', predicate: 'protocol/all-signed', config: { templateKey: 'tillstandsrapport', entityType: 'workorder', entityIdFrom: 'orderId', countersigned: true }, }], ``` The kernel evaluates the predicate inside the guarded operation's own transaction, before the handler; a throw blocks and rolls back. And because a reviewable gate is worthless if the ungated door stays open, `withdraws: ['workorder/close']` suppresses the engine's default binding so the guarded wrapper is the only way in — the in-scope `closeWorkOrder` stays composable. Every adapter must implement both; the contract suite checks. Two rules were pinned that decide how engines evolve. **Another module's tables are private**: engine data is reached through exported in-scope functions, the stable surface is entity ids, `EntityRef`s and event payloads, and a vertical that needs more adds its own side table keyed by the engine's id — never a column upstream. Engine surfaces evolve additively: new inputs are optional with behaviour-preserving defaults, shipped payload fields are frozen, and permission keys are never renamed. Boundary-lint's fifth rule enforces the first half, with a commented allow-block as the reviewable escape hatch for a one-time extraction handoff. And **engines are extracted at the second vertical, never designed ahead** — the placement spectrum runs build → template → engine → integrate, and this week's protocol engine is the rule applied. ## Five engines - **Workorder** and **invoicing** were there on Monday: a state machine that cannot skip states with append-only time and material, and a consumer that turns a fat `workorder.completed` into an invoice basis that is immutable once exported. They gained their first fifty tests on Thursday, which found two defects in the published invoicing engine: the basis total summed across currencies (100 SEK + 100 EUR came out as 200), and a replayed completion duplicated billable lines. **Both change behaviour:** a mixed-currency basis is refused at write time, and `invoicing.underlag-exported` moved to `schemaVersion` 2 with `total` as `Money` — a replacement rather than a dual-emit, because dispatch keys on the event type alone. - **Protocol** was extracted from the field-service demo on Tuesday, the day the bike-shop demo needed a condition report with a customer countersign. Sign freezes the content under a Web Crypto SHA-256 hash; countersign verifies the frozen content; responses are append-only; void, never delete. Templates and policy stay in the vertical. It also contributes the `protocol/all-signed` predicate above. - **Booking** is the platform's second invariant shape: allocation against capacity over a half-open interval. There is no locking code — a scope is one serialization domain, so read-then-write is correct — with one hard consequence: **a resource's entire calendar must live in one scope**. Held reservations expire lazily, the state machine cannot skip, participants are an append-only log, and aggregate events carry a count and no identities. - **Invites** owns `invited → accepted | revoked | expired` and nothing else. Identifiers are stored as scope-salted hashes and never returned; inviting someone twice returns the same shape and the same id; accepting checks no permission (the recipient is not yet a member of anything) and re-hashes what they present, so a leaked id is not a bearer token. Accepting emits `member.add-requested`, and membership itself is effected by an **executor** — a host-side handler that runs after commit with platform authority, the authority module code never holds. The admin log's `causedBy` joins the two halves of that trail. ## A shared control plane you can deploy The control plane is the shared layer every platform-managed vertical deployment sits on — a self-hosted single-tenant instance can still run without one — and this week it became a thing you can run. `@substrat-run/control-plane-api` is one router over `HostAdmin` — the acting staff member is unrepresentable in a request body, so it cannot be forged — and `HostAdmin` gained its read side: list tenants and scopes, read a scope record, roles, entitlements, and a filtered, cursored audit log. On top of it sit a staff console (tenants, fleet, admin log) and a deployable Cloudflare Worker that fails closed with 401 until a staff session exists. `pnpm dev` runs the whole stack on one SQLite directory — console, portal, vertical API, control plane — and a suspend in the console fails the vertical's next request closed. A vertical joins it through `ControlPlaneClient`: register a tenant, its entitlements and a scope over HTTP, then call `assertScopeActive` before every `getScope`; a transport failure fails closed, and the tenant-level cascade applies. Authentication is a service token in `x-service-token` — a vertical is a service, not a staff member. The proof was an external vertical outside the workspace, depending on the published packages at real semver, deployed to Cloudflare and gated by the shared plane, and the field-service demo ran the same way over a service binding. **Two things change behaviour for a control-plane operator.** Every rostered email used to map to one hardcoded actor, so two operators' suspensions were indistinguishable; the roster is now a D1 table with one actor per person, `STAFF_EMAILS` is gone, and an empty roster means nobody can act. And staff sign-up is gated on that roster — a probe found that anyone could create an account on the deployed portal — so an operator is granted first and sets a password second. Separately, a scope whose lazy migration failed used to show as healthy; it now carries a `migrationFailure` record with the failing version, error and attempt count, and the console shows a danger badge. The second adapter arrived with all this: one SQLite-backed Durable Object per scope, migrations on wake, outbox dispatch, the directory in a durable `ControlPlaneDO`, and the shared contract suites passing unchanged on workerd. **It forced the week's other breaking change: every `HostAdmin` method is async, takes a leading `PlatformActorId`, and — by Sunday — every *read* takes one too**, because reads are now recorded in a separate access log with a `result_count`, which is what distinguishes "called `listScopes`" from "enumerated four thousand tenants". ## Identities, orgs and revocation Identity is a seam, not a feature: `linkIdentity` and `resolveIdentity` map a provider's external id to a principal, and the kernel authorises but never authenticates. **Three things change behaviour.** The mapping is keyed per tenant — `(tenantId, provider, externalId)` — because keying it globally let two white-label tenants' user `123` resolve to the same person, a cross-tenant identity bleed that both adapters had; `resolveIdentity` now takes the tenant first, and rebinding an external id to a different principal throws. A provider must be registered as an identity **pool** with a topology, `central` (one pool, many tenants) or `tenant-bound`, and `linkIdentity` refuses an unregistered pool or a tenant it does not serve. And an org is a real record with a branded `OrgId` — before, `'acme'` and `'Acme'` were two orgs and a typo granted to a phantom — so `grantToOrg`, `addMember` and friends throw on an unknown org. Revoking anything is a **tombstone, never a delete**: a revoked tuple keeps its row and gains `revoked_at`, which the checker's walk skips, because the tuple is the evidence of why access was allowed. `removeMember` and `listMembers` exist now (`includeRevoked` is the evidence view), and the entity-parent walk honours liveness on its edges, which is what makes revoking a link possible at all. ## Push is not deploy A scope used to carry a free-text `vertical` nothing validated. The directory now has a registry: verticals, their versions — each with a permission digest, a migration digest and a deployment reference — and an admission state. A published version lands `pending`; a scope can only be bound to an `admitted` one; rejection is terminal. Channels (`dev`, `staging`, `prod`) are named pointers moved by `promoteVersion`, and promotion **refuses when the permission surface or the migrations differ from the version it replaces** unless the change is acknowledged, naming both digests in the error and recording the acknowledgement on the admin row. The two human checkpoints that used to fire at merge now fire at promotion, where an operator of a fleet actually feels them. Scopes do not follow a channel yet; binding is explicit. ## A hostname resolves to a scope One router for the whole environment — not one per vertical — resolves `hostname → (tenant, scope, vertical, surface, region)` from the directory and forwards over a service binding, so a new vertical gets custom domains for free. Bindings have a lifecycle, `pending → verifying → active`, and only `active` resolves; exactly one is canonical per scope and surface; a hostname bound elsewhere is refused; hostnames are lower-cased on write, because `ACME.example.com` and `acme.example.com` had bound two scopes. The console's new **Domains** view binds and activates them (activation is manual — DNS validation and certificates are not built), and every scope link the console offers now comes from that map. On the vertical side, `readRoutedNode` reads the tenant, scope and surface the router asserts in `x-substrat-*` headers and trusts them only when `x-substrat-router` carries the shared secret, compared in constant time; the router strips every inbound `x-substrat-*` header before setting its own. **This changes behaviour for the deployed field-service demo:** it needs the router in front of it or `STANDALONE=true` (the flag has since been renamed `ALLOW_DEV_NODE`, and it authenticates nobody — it only names the one node an unrouted local instance serves), and every router-fronted deployment needs a `ROUTER_SECRET` matching the router's; an unrouted instance does not read it. Residency is configuration, not topology: the region is a column on the binding, pinned per hostname. ## Demos The reference verticals got their names on Sunday: the field-service demo is **Callout**, the HR demo is **Meridian**, the bike shop is **Handlebar**; RallyPoint and the coffee shop keep theirs. Two are new this week: - **Meridian** (HR) is the shape-breaker — no ready-made engine fits, so leave, project time and expenses are vertical code; it reuses only the protocol engine for onboarding checklists. Absence and time entries are append-only ledgers, `national_id` is the crypto-shred target, and an employee holds their permissions only as grants narrowed to their own record, which is what "sees only their own data" means here. - **RallyPoint** (a padel club) is the booking engine's first consumer and the end-to-end walk of invites: a club admin invites a player, the player accepts, the executor writes the membership, and the club's own consumer creates the member row it cares about — two records, two owners. The coffee shop demo gained a separate back-office app against the same API (customer-facing and staff-facing are chrome and audience, never a second source of truth), and a leak where unpublished drafts could be listed is closed inside the operation. Callout gained customer and price-list screens, and its protocols moved to the engine through an append-only migration. **Templates are now something a customer receives.** Handlebar and Meridian trusted any `x-principal` header — Meridian defaulted an unauthenticated request to a named employee — and both now refuse it unless `ALLOW_DEV_HEADER=true`, as Callout already did. Every demo's seed is split into `provisionX()`, which creates one tenant, one scope, its roles and an owner, and `seedX()`, which builds the cast on top; before the split, every instantiation shipped a second company nobody owned with an admin whose demo password was public. Demo dev ports moved to a private `887x`/`527x` block to stay clear of the Vite and Wrangler defaults. ## Also - **Permission diff has a mechanical home**: each vertical exports `MODULES` and `ROLES`, `pnpm lint:permissions` renders them into a checked-in `PERMISSIONS.md`, and CI re-emits it with `--check`, so a widened role cannot merge without appearing in the diff. - **Docs**: the site got its design system and a landing page, a *How Substrat compares* guide, a *Verticals* section, an *Authentication & identity* concept page and a *Platform* concept page; every package's npm page links to its reference. Decisions on record this week: the rename and the licence, the placement spectrum, the engine compatibility surface, the control plane as the shared layer, identity topology for three audiences, the admin's record-keeping half as a vertical, hosting as the paid layer, tombstoned revocation, staff read auditing, the builder portal as the platform vertical, the IdP never fronted, and one environment-wide router. - **Known gaps recorded** by the from-scratch runs: `ctx.link` has no unlink (the tombstone work above is the first step), the entity walk depth ceiling is 3 and fails closed silently, erasure keys on one subject per event, and `kernelContract` in a manifest is not validated at runtime. ## Released | Package | Span | |---|---| | `@substrat-run/contracts` | 0.1.0 → 0.6.0 | | `@substrat-run/kernel` | 0.1.0 → 0.6.0 | | `@substrat-run/adapter-sqlite` | 0.1.0 → 0.6.0 | | `@substrat-run/contract-tests` | 0.1.0 → 0.6.0 | | `@substrat-run/adapter-cloudflare` | 0.2.1 → 0.6.0 | | `@substrat-run/control-plane-api` | 0.4.0 → 0.6.0 | | `@substrat-run/engine-workorder` | 0.1.0 → 0.3.3 | | `@substrat-run/engine-invoicing` | 0.1.0 → 0.3.3 | | `@substrat-run/engine-protocol` | 0.1.0 → 0.3.4 | | `@substrat-run/engine-booking` | 0.1.0 | | `@substrat-run/engine-invites` | 0.0.2 | | `@substrat-run/boundary-lint` | 0.0.1 | | `create-substrat` | 0.0.0 | Everything is new to npm this week. The kernel group's five minor bumps in six days are the pre-1.0 pace stated plainly: `HostAdmin` went async and grew an actor argument, Zod moved a major, and the identity key changed shape, all without a deprecation window.