Skip to content

Week 29 · 13–19 July 2026

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, EntityRefs 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

PackageSpan
@substrat-run/contracts0.1.0 → 0.6.0
@substrat-run/kernel0.1.0 → 0.6.0
@substrat-run/adapter-sqlite0.1.0 → 0.6.0
@substrat-run/contract-tests0.1.0 → 0.6.0
@substrat-run/adapter-cloudflare0.2.1 → 0.6.0
@substrat-run/control-plane-api0.4.0 → 0.6.0
@substrat-run/engine-workorder0.1.0 → 0.3.3
@substrat-run/engine-invoicing0.1.0 → 0.3.3
@substrat-run/engine-protocol0.1.0 → 0.3.4
@substrat-run/engine-booking0.1.0
@substrat-run/engine-invites0.0.2
@substrat-run/boundary-lint0.0.1
create-substrat0.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.

The hard parts, hosted.