Week 32 · 3–9 August 2026
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:
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 <scopeId> --domain test.example.com
substrat scope bind <scopeId> --version <id> --snapshot- Re-running a tag renews its 72-hour deadline instead of letting the preview die at the original one;
--ttl nonepins 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. --emptyprovisions a clean-room preview with no source — a brand-new vertical's first environment, or a test environment with its own seed.scope domainbinds a custom domain to any scope you own, so a pinned preview can carry a stable address;scope bindpins one scope to one version — a canary, a held-back tenant, a catch-up — where a promote moves the fleet.--snapshotforks 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
SWEEPERstore tosubstrat.runtimeNeedsand export theSweeperDOthe 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: trueinpackage.json; once an operator grants the verticalemailSender,PlatformRelayEmailTransportsends through the control plane's relay from the platform's sender. The vertical never holds a mail credential. - Calling another vertical. A
fetchfrom 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 programmaticassets.bindingis 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:
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/<slug>, /apps/<id>/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 —
nullwhen none was taken. A store that fails aborts the reap with the scope intact; asking for a backup where no store is bound is501, 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. - 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_DAYSandACCESS_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 /metersreturns 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 · Nbadge; 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=<invitationId> 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 substratscaffolds a vertical that is pushable on day one — a worker, the full/internalcontract,substrat.permissionsandsubstrat.runtimeNeedsinpackage.json;substrat pushwith neitherruntimeNeedsnor awrangler.jsoncrefuses with the remedy instead of a file-not-found; a vertical that declares noentitlementsinstalls holding its own slug instead of nothing; a vertical that provisions tenants for others declaressubstrat.provisionsinpackage.jsonand 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, sopnpm installno longer needsnode-gyp; Node 22 or newer is required. - Docs: an Environments & previews guide, a Backup and recovery section on the control-plane page, a reference for
@substrat-run/vertical-host, thesubstratinstall-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().