Week 31 · 27 July – 2 August 2026
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 <scopeId>, or --vertical <slug> 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 <scopeId> --file <backup> 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:
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:
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:
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: <moduleId> }. 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:
const plan = await ctx.entitlement('manyfold'); // { key, plan, quota, expiresAt } or nullExpiry 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:
const cfg = resolveScopedEnvSpec(spec, env, delivered); // delivered > env > defaultWith 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 <slug> lists a workspace's installs and substrat scope status <scopeId> 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:
"substrat": { "surfaces": [{ "name": "app", "label": "App" }, { "name": "eka", "label": "EKA" }] }Every declared surface gets its own platform URL at install (<slug>-<surface>.<jurisdiction>.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 <slug> --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 <BINDING>__<TENANTID> 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 <slug> 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 <scopeId>, 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
substratskill now lands a design document you approve before any code, then hands off tonew-verticalto build it. Each demo's declarative surface lives in its ownmanifest.tsbesidemigrations.tsandmodule.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.