Skip to content

Week 35 · 24–30 August 2026

This week changed the API every vertical gets for free. Errors are structured documents instead of sentences, a retried write no longer risks doing the work twice, and two people editing the same record no longer silently overwrite each other. The kernel gained two reads you used to write by hand, and every permission check an app declares now comes with a receipt proving it holds. A vertical can now call an AI model through the platform without ever holding a provider credential, and the wall clock finished leaving the wire — an operation that used to accept a now no longer does. A new demo — ticket0, an AI-assisted support desk — went live, including on this site. And if you operate a hosted install, several things about secrets and the owner seat changed that you should read.

Errors are structured now

Every API error is an application/problem+json document. Before, each vertical answered { "error": "some message" } and a client had to guess the cause from the wording. Now the body carries a machine-readable code (the platform's vocabulary — conflict, not_found, validation_failed, …) and a reason (yours — out_of_stock, already_signed), alongside the human message:

json
{ "status": 409, "code": "conflict", "reason": "out_of_stock",
  "detail": "out of stock: SKU-14 — 2 available, 5 requested",
  "error": "out of stock: SKU-14 — 2 available, 5 requested" }

The error field is still there, so nothing you have written stops working — but read code and reason from now on; error is deprecated. In your own operations, throw substratError('conflict', message, { reason }) and your clients get the same shape; a vertical that owns its own app.onError answers with problemResponse(c, err), and the host reference now documents both it and the status each error code maps to. When input validation fails, the response lists the offending fields under errors, on the local runtime and on Cloudflare alike.

One thing changes behaviour. The input schema an operation declares is now enforced by the platform before your handler runs — on HTTP calls, in tests, in seeds and on schedules. Previously the declaration described the shape and the handler was on its own to check it, and across the demos most did not. If your declared schema was looser or stricter than what your handler checked, some calls that used to reach your code now get a validation_failed first. Your handlers no longer need to parse their input themselves.

That now includes the engines. Four of them — work orders, absence, invoicing and protocol — declared their input schemas and never handed them to the host, so a call reached the handler with whatever arrived on the wire. They do now, on every path in, and the parse runs before the permission check. The practical effect on a caller is that a sloppy extra field is stripped rather than forwarded, and a malformed one is refused where it used to reach a row lookup.

Safe retries, and no more lost updates

A client whose request times out cannot know whether the work happened. Retrying used to risk creating the thing twice — a second work order, a second invoice. Now any write can carry a key the client chooses, and the same key on a retry returns the first response instead of doing the work again:

http
POST /api/workorders
Idempotency-Key: dispatch-4471

The replay is marked with Idempotency-Replayed: true; the same key with a different request is refused. Nothing to configure — every write on every vertical honours it.

The other hazard is the lost update: two people open the same record, both save, and the second silently destroys the first. An operation can now declare that it writes over an entity — one line, concurrency: { over: 'facility', idFrom: 'facilityId' } — and three things follow. Every response for that entity carries its version as an ETag, a write whose If-Match is stale is refused with 412 Precondition Failed, and the generated browser client remembers the tag it read and sends it on the next write, so your app writes no header code. Under the hood every entity now has a version (ctx.versionOf(ref)), with no new column and no migration on your side.

An entity's history, the denial log, and acting as a user

Three things you used to build yourself, or could not do at all:

  • Entity history. readTimeline(ctx, ref, page) returns what happened to one entity — every event, who did it, when — and readHistory adds the payloads. Five demos had each written this query by hand and disagreed about the ordering; use the kernel's, and page with the cursor it gives you.
  • The denial log. Every refused permission check in production has been recorded for a month; now you can read it. The console shows it per scope, and the control-plane API serves it — who was refused, which permission, against which record, how often.
  • Acting as a user. A staff member can open a read-only, fifteen-minute session as one of a tenant's users to see what they see — "the invoice screen is empty for Anna" — with the real actor recorded on every event, history entry and denial that session touches. The session answers with Anna's permissions, not the staff member's. If you render a timeline, expect an impersonation field on entries made that way.

Call a model without holding the credential

A vertical that wants to answer, summarise or classify with an LLM used to need its own provider account, its own token as a worker secret, and its own idea of what a call cost. It now asks the platform:

ts
const models = createModelHost({ env, aiBinding: env.AI, guard, record });
const run = await models.run({ spec: 'cloudflare:@cf/meta/llama-3.1-8b-instruct-fast',
                               attribution, system, prompt });

A model is named provider:model from a catalogue that today covers Anthropic, OpenAI, Google, Mistral, Qwen, Cloudflare Workers AI, Scaleway, Ollama and any OpenAI-compatible endpoint. The credential is the platform's, never yours, and Cloudflare's models are reached through a runtime binding rather than a token at all — so a vertical that answers with an LLM ships no AI secret. A guard you supply runs before the bytes leave, and every call produces exactly one usage line: tokens as the provider reported them (zeros and a reported: false flag when it reported none, never an estimate that quietly becomes a bill), the list price computed platform-side, and five fixed attribution keys — tenant, scope, vertical, version, operation. A call whose usage line cannot be recorded fails, rather than looking like one that succeeded. status(spec) answers a settings screen without running anything, including the hosting disclosure — which vendor, in which location, and what is sent — so your users can be told where their text goes.

What is sent to a provider through Cloudflare's gateway is never logged: counts, model, cost and duration are, the prompt and the answer are not. Model usage appears in the console as its own meter, per tenant × vertical × model, with list and billed side by side and unpriced calls counted rather than silently valued at zero.

Two limits worth stating plainly. Bring-your-own-key is not there yet — a tenant's own provider key is sealed platform-side and can't be handed to a vertical's model host, so that needs the call itself to run platform-side. And a vertical can still reach the Workers AI binding directly, outside the ledger and unattributed; the binding is a capability, not a value, so there is nothing to steal, but it is not yet a hard boundary.

If you were pinning a Workers AI model: @cf/meta/llama-3.1-8b-instruct was deprecated upstream and now fails outright. The default is @cf/meta/llama-3.1-8b-instruct-fast.

The clock leaves the wire

One thing changes behaviour. The booking engine's operations no longer accept a now input. Every one of them — hold, confirm, expire, join, leave, open, move, get, list, availability — took an optional instant that was preferred over the platform's clock, so anyone holding booking:confirm could confirm a hold that had already lapsed by back-dating it, or sweep someone else's live hold by post-dating it. A now sent over HTTP is now stripped and every operation judges against its own instant. The in-scope functions keep their now parameter, so a vertical composing booking inside its own transaction compiles unchanged. RallyPoint, which carried now on eleven of its own inputs and forwarded them, now declares it nowhere — including on its reads, because the read that answers "is this match still open?" is exactly the one a chosen instant gets to answer for you.

A tightened input in metering. occurredAt was bounded behind, by the close horizon, and not ahead. An entry post-dated past every period anyone will realistically close was aggregated into none of them — billable usage leaving the stream silently, with no later pass to pick it up. It is now refused more than five minutes ahead of the clock, with a occurred_at_ahead conflict reason. Recording without an occurredAt at all, or with an ordinary back-dated observation, is unchanged.

Permission checks come with a receipt

Every app and engine now carries a CONFORMANCE.md, regenerated by pnpm lint:conformance: for each operation that narrows a permission to one record, the kit proves both halves — the right holder gets through, anyone else is refused — against your real handlers, and lists the checks it cannot drive with the reason. Two new ways to declare a check arrived with it: an operation whose target may be one of several entity types says which field decides (entityFrom), and an engine that checks a reference the caller owns names the field carrying it (refFrom). One declaration in the work-order engine was found to name the wrong permission for start; the declaration is fixed and nothing you hold changes.

Engines protect you from version skew

The work-order, booking, protocol and absence engines now validate everything they hand back to you against the schema they publish, and read only the columns that schema names. The failure this prevents is quiet: a vertical built against one engine version, running against a newer one, reading a field that moved and showing wrong data on a screen rather than an error. Now that is an internal error, logged, never a wrong number. The work-order engine also became fully composable — assigning, starting, reporting time and reporting material are plain functions you can call inside your own transaction, like creating and completing already were. Every engine's header now states whether it is composed by call or by event, so an absent in-scope export reads as intent rather than an omission.

A new lint rule will flag existing code. Catching an error from an engine call anywhere other than inside ctx.atomic is now a boundary-lint violation (rule R7): a bare catch around an engine call keeps the engine's half-written rows and commits them. The fix is one wrapper — ctx.atomic(() => …) — and there is deliberately no opt-out.

A new demo: ticket0, an AI support desk

ticket0 is the eighth demo vertical: a chat widget you embed on your own site, an inbox for your staff, a portal for your customers, and an assistant that answers out of your own documentation. Try it — the bubble on Ask the docs is ticket0, answering from this site's llms.txt.

The idea it exists to show: the assistant is staff. It has an account and a role, so "may the AI answer customers directly, or only draft for a human?" is not a setting — it is which permission its account holds. Two desks are seeded to prove it: one whose assistant may reply to customers, one whose assistant may only draft, running the same code. It also shows metering by call (an append-only token ledger, so a retried turn cannot double-bill) and invites for staff joining a desk.

Embedding is one tag:

html
<script src="https://desk.example/widget.js"
        data-user="marcus@parcelbay.com" data-signature="a3f1…"></script>

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 and the dashboard page — the two pages you read when wiring and operating an install — not only in the auth reference; the impersonation stamp is described on events and permissions; and the Scrive connector page 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, Manyfold, create-substrat), the identity concept names which demos are OIDC-only, and the contract-tests reference covers all thirteen suites and the entity-check kit. The reference pages for the kernel, scope host, contracts and adapter-sqlite enumerate the seams that exist today; the model and API design describe the three shapes a narrowed permission takes and the concurrency joins above; the work-order and booking 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

PackageSpan
@substrat-run/contracts0.88.0 → 0.92.1
@substrat-run/kernel0.88.0 → 0.92.1
@substrat-run/adapter-sqlite0.88.0 → 0.92.1
@substrat-run/adapter-cloudflare0.88.0 → 0.92.1
@substrat-run/vertical-host0.88.0 → 0.92.1
@substrat-run/vertical-auth0.8.1 → 0.9.0
@substrat-run/control-plane-api0.88.0 → 0.92.1
@substrat-run/contract-tests0.88.0 → 0.92.1
@substrat-run/model-emit0.8.0 → 0.8.7
@substrat-run/boundary-lint0.2.0
@substrat-run/psl0.2.3
@substrat-run/dev-issuer0.1.2 → 0.1.6
@substrat-run/engine-workorder0.8.4 → 0.10.0
@substrat-run/engine-booking0.6.0 → 0.6.5
@substrat-run/engine-invoicing0.9.4 → 0.9.8
@substrat-run/engine-protocol0.11.4 → 0.11.9
@substrat-run/engine-invites0.4.8 → 0.5.2
@substrat-run/engine-absence0.5.0 → 0.5.4
@substrat-run/engine-metering0.3.8 → 0.4.2
@substrat-run/connector-scrive0.13.4 → 0.13.8
@substrat-run/cli0.25.8 → 0.25.13
create-substrat0.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.

The hard parts, hosted.