Skip to content

The platform layer

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.

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 and custom-domain issuance, the ops console, the builder dashboard, per-PR previews, 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 itPer-vertical
RoutingResolves hostname → (tenant, scope, vertical, surface), then dispatchesThe surfaces a manifest declares
Custom domainsHostname issuance, DNS validation, certificate lifecycle
TenancyTenant registry, scope directory, provisioning lifecycle
IdentityAuth callbacks, principal derivation, capability minting
EntitlementsThe store and the module-load gateThe entitlementKey each manifest declares
History & analyticsThe event spine and its history tier
CopiesSnapshots, forks, per-PR previews, per-scope PITR, directory backup/restore
The outside worldConnections and their sealed credentials, the outbound egress seam, the email relayThe declared allowlist and the connectors a manifest requires
AdminDirectory, audit log, access log, ops-failure log; the console and the builder dashboard
ExecutionThe 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. 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 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). 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

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

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, 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. 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 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 planquota, 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.
  • 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 for the entities and addressing model, and Modules & the manifest for what a deployment is made of.

The hard parts, hosted.