Week 34 · 17–23 August 2026
A vertical has always declared its entities and operations. This week that declaration stopped being documentation and became the source: the migration journal, the browser client, the route table and the OpenAPI document are now derived from spec/model.ts, and each derivation is gated in CI, so a second description of a declared thing can no longer drift unnoticed. Two changes to what module code may do come with it — an engine call inside your transaction now has a boundary of its own, and the wall clock is gone in favour of ctx.now(). Reads learned to page through headers, declare their filters and search an index the kernel builds; a permission denial through the host is a 403 rather than a 500; and a local OIDC issuer means the login you exercise all day in development is the one production runs. Every engine now declares its operation surface, and the kit that checks handlers against it found the reference implementation misdeclared.
The model is what everything else is derived from
Two things change shape, deliberately. entityRelations is an allowlist — an entity may legitimately have more than one parent, and two already did — so the singular parent is renamed parents and model.json carries an array. And emitTables moved out of @substrat-run/contracts into a new build-time package, @substrat-run/model-emit; an import of it from contracts no longer resolves.
What the model now declares, and what checks it:
An entity registry the manifest's entity names are checked against, with relations whose both sides are checked against the engines a vertical composes — a typo in either position is a compile error listing the composed set. Local-to-local edges are derived from the entities' own
parentsand cannot be declared twice.The operation surface, with
defineOperationslearning the composed engines, so an operation can emit about the thing an engine owns — a checklist toggle on a protocol instance was inexpressible before, and it took converting a 159-operation production vertical to find that out.What a permission checks against. A bare key was ambiguous in the direction that fails open —
list:manageat the scope or on one list looked identical in the model and only the handler knew. Now:tspermission: { key: 'list:manage', entity: 'list', idFrom: 'listId' }entityis checked against the declared entities and composed engines,idFromagainst the operation's own input;resolved: '<reason>'covers the case where the handler must find the entity. One of the two is required, so a narrowed check cannot silently say nothing.renamedFrom— the one declaration a diff genuinely cannot derive.Lifecycles. A
statusenum used to describe its transitions a second time as hand-written guards in operation bodies; booking held the same seven states in two independent literals.defineLifecyclesdeclares the machine once —initial, per-stateon(an edge) andallow(a precondition that moves nothing),terminal— and the kernel evaluates it. Work orders, booking, invoicing, Manyfold and the shop are declared.Consumers typed against the engines a vertical composes. Composing by event was three plain strings and an
unknown, which is how a vertical consumedprotocol.signedand notprotocol.countersigned, and every two-party contract stayed pending forever.consumersFor<[ProtocolEvents]>()types the payload from the producer's declaration, rejects an event no declared engine emits, and refuses a half-handled completion group naming the missed event.
From that model, @substrat-run/model-emit derives the DDL — id as a non-null primary key (a hole every hand-written table had), key as UNIQUE, parents as REFERENCES, an enum as CHECK, a boolean as INTEGER; a composite key is a composite, and a stored bare boolean is refused. Parity tests in Callout and Handlebar assert the emitted journal matches the checked-in one, and against a 55-table production model the emitter agreed on 54. It also derives the browser client and the route table (pnpm lint:client): a vertical's app/src/api.generated.ts is standalone TypeScript with no imports, and a schema the printer cannot spell exits 2 naming the field rather than degrading to any. The cautionary tale is the Todo demo, whose hand-written client was 91 lines a person maintained by remembering to; when a read became paged and search was added, the client learned about neither, and the app rendered the first twenty items of a list as though that were the list. Nothing was red, because nothing could be.
To find what the derivation actually costs, one vertical was built forward from a one-line brief rather than retrofitted — the existing demos had all had their models added a month after being written, so every check asked the backward question. Seven platform corrections came out of it that six demos never surfaced, the largest being that module code had no way to create a grant at runtime, so an app where a person shares their own record had no supported mechanism. The concepts page for the model was written and corrected, and the rule that an engine is composed by call or by event, and must say which in its header, is on record.
Engine calls are sub-transactions now
Catching an error from an engine call used to be forbidden outright, for a good reason: the call runs inside your transaction with no boundary of its own, so a bare catch leaves you holding the partial writes its invariants were protecting, and then commits them. Now:
try {
await ctx.atomic(() => completeWorkOrder(ctx, { orderId, billable }));
} catch {
// the engine's rows, events, links, grants and platform intents are all gone;
// your own writes survive, and it still commits once
}A succeeded atomic is still provisional — if the operation later throws, its writes go with it. Sub-transactions nest but must not interleave; starting two concurrently throws. Outside ctx.atomic, catching an engine error remains forbidden. Both runtimes were probed before the design was fixed: the Durable Object runtime forbids SAVEPOINT outright and insists on its own nested transactions, so the two hosts share a contract and not an implementation.
Module code lost the wall clock
A new lint rule will flag existing code. Time comes from ctx.now(), and an argless new Date() or Date.now() in module code is a boundary-lint R6 violation — the same class of ban as node:*. Ninety-five hand-rolled timestamps across the engines and demos were stamping rows the host could not see, while the instant brand claimed to be "stamped kernel-side".
The value is stable for the whole invocation, so a row and the event announcing it agree about when; both hosts route emit's occurredAt through the same value. A host takes a clock, the same seam as fetch, which is what lets a scenario assert elapsed time with manualClock instead of sleeping. Reading a stamped value — new Date(row.created_at) — is ordinary data handling and stays allowed; the ban is on originating a timestamp. Code that genuinely needs the real clock, a JWT whose exp a remote server judges, opts out with a reviewable boundary-lint-allow R6 block.
Reads: paging in headers, declared filters, a search index
Paging keeps the body's shape, not its size. A paged read's body stays what it always was — a bare array or the vertical's own object — and the walk rides in response headers:
Link: <https://api…/customers?limit=20&cursor=01J9A…>; rel="next"
X-Total-Count: 340The first design wrapped the body in { entries, nextCursor }, which renames a live endpoint's contract with no way to soften it; for a bare-array list there is no compatibility window even possible. So a vertical can now page all of its list reads without breaking a single parser — but a client that used to read the whole list now gets the first page, and has to follow Link for the rest. That is a change in what comes back, not in how it is shaped, and the Todo client above is what it looks like when nobody follows the header. The total is opt-in.
A list read declares its filter and sort vocabulary. Twelve reads across four engines and four demos answered with whole tables, and the engines carried some thirty-six hand-written ORDER BY clauses none of which a caller could choose — a vertical wanting a different sort had no path but to fork the engine. paged is now one of two halves: over: { entity, sortable, filterable }, where the kernel composes the WHERE, ORDER BY, keyset comparison, LIMIT and matching COUNT from the entity's declared columns and provisions the indexes; or sortKey, where the handler composes because it genuinely must — a timeline over the kernel's outbox, a read through a correlated subquery, a portal read that decides visibility per row. An absent over reads as intent.
searchables is real. The declaration had been in the contract since the beginning and nothing read it; every search shipped was a client-side includes over a fully fetched list, which paging turns into a search of the first page only. Now:
...manifestEntities(calloutEntities, {
searchables: [{ entityType: 'customer', fields: ['name', 'number'] }],
}),derives an external-content FTS5 index and its maintenance triggers, appended to the module's own migrations, and ctx.search(entityType, term, …) reads it. A field the entity does not have is a compile error, and so is a composite-keyed entity. The Todo demo declares search over item text and adds two search reads.
Smaller corrections in the same area: a read's query string is documented in the emitted API and a GET stops claiming a request body; http.method accepts PUT; mountOperations types the values a URL carries.
Every engine declares its operations, and a kit checks the handlers
All engines now declare their operation surface, and a conformance kit checks that each handler honours what it declared — the permission key, the check's shape, the input schema. Getting the protocol engine there meant breaking a require cycle: its input schemas moved to their own leaf, a scripted move verified against the previous export surface at seventy names with none dropped. The invoicing engine's declared surface is three reads and no writer, and a test asserts that absence — this engine is composed by event, and a creating operation appearing there would be a second way in past immutable-after-export.
Extending the kit to six more packages found the reference implementation misdeclared. Callout's timeline declared customer:manage at the scope while its handler checked workorder:read on the entity — wrong key and wrong shape — so PERMISSIONS.md said a technician could not read a timeline that a technician could read every time. Handlebar's timeline had the right key and the wrong shape, which read as the portal's isolation stated backwards. Both now declare { key: 'workorder:read', entity: 'workorder', idFrom: 'entityId' } and wire the kit. That is the outcome that makes a kit worth having: the thing everyone copies was the thing that was wrong.
Errors from the host are honest
A permission denial through mountOperations is now a 403. Every failure the mount could produce came back as 500 Internal Server Error — a denial was indistinguishable from a crash, and so was an input that failed to parse. The kernel's own vocabulary is mapped and nothing else is: PermissionDenied → 403, a ZodError or a non-JSON body → 400, a runtime fault → 502, an HTTPException passes through as itself, and anything else is re-thrown unchanged so a vertical's domain errors still reach its own app.onError. The mount decides the status, never the shape; an app that owns an error envelope keeps writing the body.
On the vertical's side, the Todo demo is the first to throw with a code — not_found, precondition_failed — instead of prose the host had to pattern-match; its routes no longer match on error text at all.
Local login is the production round-trip
Every vertical carried an x-principal header: a name the server was told and believed, one environment variable from being a live impersonation bypass, and a fork of the auth path four files deep so that the login exercised all day in development was one no deployment ever ran. @substrat-run/dev-issuer replaces it: a real OpenID Connect provider — discovery, JWKS, authorization code with PKCE, a signed ID token, RP-initiated logout — whose only shortcut is that /authorize lists names instead of asking for a password. It is stateless (the code is a short-lived JWT), its signing key is checked in and public on purpose, and it is never deployed. POST /dev/token {sub} mints a token with no browser for tests and headless verification. For a scaffolded project this changes behaviour: the dev header is gone, the … dev script starts the issuer, and changing issuer is a change of OIDC_ISSUER.
Scaffold, CLI and the agent's seat
The scaffold a user gets is now run, not trusted. The template under create-substrat was a complete vertical none of whose gates ever ran in CI — it had been broken for four minors before anyone noticed, and was broken again four days after the fix. Two gates close that. Post-release and weekly, a job runs npm create substrat at the published version, installs from the registry with no workspace links, and runs the three gates a scaffold ships with. And on every PR the template is compiled against the workspace, which is the gate that makes a non-additive engine surface red in its own PR rather than after publish — the paged listOrders had reached npm create substrat failing all three gates. The template's pins are frozen to what ships, the deployed surface is the one you developed against, a UI the push would never serve is refused at push, a dev server that cannot work refuses to boot, and the design document has one name: spec/concept.md.
A new lint rule will flag existing code. cloudflare:workers joins the R2 ban. Its ambient env handed module code every binding and secret the script declares — including its own SCOPE namespace, which reaches another scope's data where ctx.sql cannot — and the layer rules stayed green over exactly that. Sharper for engines than for verticals: it was the invariant-owning layer that could reach every scope of whoever composed it.
For an agent working in a scaffolded project: a SessionStart hook announces that this is a Substrat vertical, where the rules and the flow live, and the docs slice matching the kernel version actually installed in node_modules, so an upgrade opens by stating the jump rather than letting the agent discover it by being wrong. The skills travel to projects that were never scaffolded — claude plugin marketplace add https://substrat.net/marketplace.json — and the plugin scaffolds and then defers to the project's own files rather than carrying a monorepo-shaped flow. Every demo and the template carry a .claude/launch.json, emitted from the substrat.devServers block and gated by pnpm lint:launch --check, so an agent starts the dev servers, opens the app and verifies its own change instead of trusting a green suite that never boots the HTTP layer.
Connectors: the real document, the right signatory
The Scrive connector used to send an attestation sheet — one page naming the template, the parties and a content hash — and ask a counterparty to sign it with BankID. Now the vertical uploads its rendered document onto the protocol instance and names it when binding:
const doc = await attachments.upload({ entity: { entityType: 'protocol', entityId }, … });
await scope.invoke('protocol/bind-document', { instanceId, contentRef, contentHash, documentAttachmentId: doc.id });The freeze event carries the id and the connector sends those bytes; bind nothing and the attestation sheet goes out unchanged. It is by id, never by search, because the return path lands the signed copy on the same instance.
The account holder is the sender, never silently a signatory. Measured against the testbed: Scrive binds the author party to the API account and overwrites the name and email you send, which the connector had mapped from signatureKind: 'primary' — so the account owner was invited to sign every contract, whoever the vertical named, and the fail-closed reconcile could never attribute it. The author is now a non-signing sender. A signature request can carry how a party is reached — an email address, sealed to the connector, never a personal identity number — and a declared connection grant repairs itself without touching the credential; the Scrive catalog entry could not grant protocol:read, and can.
For platform operators
- Tracing is a rate, on TEST.
VERTICAL_TRACE_SAMPLINGadds atracesblock to every pushed script; absent (prod, today) nothing changes. The finding behind it:observability: { enabled: true }had enabled logs and zero spans, so the fleet was not tracing. - Declared egress is checked against what a version reached.
GET /verticals/:slug/egressjoins the platform'sfetchspans against the version's declaredoutbound, and the console renders the difference in the Outbound column beside Admit — an undeclared host shows its origin, and from a durable object — not enforced says why it was never refused. Unenforced is not undeclared, and unused is not a fault. - A refusal before egress is no longer reported as the provider's; a store that cannot be minted answers with a diagnosis; a declared per-tenant store now reaches the tenants that already existed when it was declared.
- In the console, an auto-admitted version says so and the vouch has a button; a narrow viewport keeps its buttons.
- Required per-worker secret keys are marked, so a blank one cannot ship.
- Dependency policy: anything whose identity must be shared with a consumer is a peer dependency, so
zodis a peer of the published packages rather than a dependency — theexpected a Zod schemafailure was two copies in one tree. The contract-tests package had shipped 130import("zod")references in its.d.tswhile declaring zod nowhere.
Also
- Docs: the site is published as markdown —
llms.txt,llms-full.txtand a twin of every page, emitted from the same sidebar the nav renders from; the rules an agent must not violate have a page andllms.txtis oriented around them; one page describes how any agent discovers and works with Substrat; the enforcement story has its own page; five diagrams, with the engine state machines read from the code, reachllms.txttoo. Every page was refreshed against its source and freshness made mechanical. Convex has its own entry in the comparisons. The design-interview skill now decides the auth seam. - The decision record:
docs/moved into strategy, architecture, engines, rfc and briefs; the decision log became one file per decision with every status verified; three missing engine docs were written and the internal corpus gated; the acceptance benchmark was retired and D-28's dual-emit clause narrowed; D-46's Durable Object limit is recorded as enforcement-only, not invisibility. - Builder studio: a model phase with the direction rule made mechanical, a build phase that reads the approved model, eval fixtures that start at the prompt so the interview itself is measured, a token budget, a command deny-list that is asserted rather than executed, and scratch projects kept out of the repo's own gates.
Released
| Package | Span |
|---|---|
@substrat-run/contracts | 0.66.0 → 0.87.0 |
@substrat-run/kernel | 0.66.0 → 0.87.0 |
@substrat-run/adapter-sqlite | 0.66.0 → 0.87.0 |
@substrat-run/adapter-cloudflare | 0.66.0 → 0.87.0 |
@substrat-run/control-plane-api | 0.66.0 → 0.87.0 |
@substrat-run/contract-tests | 0.66.0 → 0.87.0 |
@substrat-run/vertical-host | 0.66.0 → 0.87.0 |
@substrat-run/vertical-auth | 0.8.0 |
@substrat-run/model-emit | 0.1.0 → 0.7.3 |
@substrat-run/dev-issuer | 0.1.0 → 0.1.1 |
@substrat-run/boundary-lint | 0.1.0 → 0.1.2 |
@substrat-run/cli | 0.24.1 → 0.25.7 |
create-substrat | 0.4.2 → 0.7.3 |
@substrat-run/engine-workorder | 0.3.64 → 0.8.3 |
@substrat-run/engine-invoicing | 0.5.22 → 0.9.3 |
@substrat-run/engine-protocol | 0.6.2 → 0.11.3 |
@substrat-run/engine-booking | 0.1.61 → 0.5.3 |
@substrat-run/engine-invites | 0.1.11 → 0.4.7 |
@substrat-run/engine-absence | 0.1.1 → 0.4.7 |
@substrat-run/engine-metering | 0.1.1 → 0.3.7 |
@substrat-run/connector-scrive | 0.8.1 → 0.13.3 |
@substrat-run/model-emit and @substrat-run/dev-issuer are new this week. Twenty-one kernel-group minors in one week is the model work landing piece by piece; the shape changes a consumer meets are parents, the emitTables move, and zod as a peer.