Skip to content

Week 36 · 31 August – 6 September 2026

This week the push stopped trusting and started checking: substrat push now runs the platform's layer rules before it uploads, the generated CI workflow gates a deploy on your own tests, and the verdict travels with the version, so an unchecked push is visible in the dashboard rather than indistinguishable from a checked one. Every vertical became an MCP server — one tool per operation, behind the same permissions, with nothing to configure. The hosted issuer learned to sign people in with Microsoft, with BankID, and with any OIDC provider you point it at, and a new Fortnox connector reads a company's books with no refresh token to lose. Two guard rails that used to be conventions are now mechanisms: module code cannot write the platform's own tables any more, and an engine cannot read with a star. And a push now repairs its own installs, which closes the classic gap where new code arrives at a scope that was provisioned before the thing the code depends on existed.

Your push is checked, and the verdict travels with it

One thing changes behaviour. substrat push now runs the layer rules — the bans on ambient environment access, on reaching into another module's tables, on the wall clock, on swallowing an engine error — over your source before it builds anything, and refuses the push on a violation, naming the file, the line and the rule. Until now those rules were checked in exactly two places, neither of which was your project; a vertical developed outside this repository was uploaded having been checked by nothing. A push that used to succeed can now fail — the message tells you what to fix, and --skip-lint exists for an emergency, announcing itself loudly in the output.

The gate leaves a receipt. Every push records whether the rules passed, were skipped, or never ran (an old CLI, a direct API call), and the dashboard marks an unchecked version with an "ungated" chip. A clean push looks exactly as before; what changed is that a bypass is no longer invisible.

The generated deploy workflow gates too: between the build and every upload it runs whichever of typecheck, test and lint:boundaries your package declares, so a red test can no longer ride a green deploy. And the promote step now moves the production pointer only while its run still owns the release — two runs resuming in the wrong order can no longer point production at older code under a newer version number. A superseded run uploads its version, warns, and leaves production where it was.

Two checks are now reachable without pushing anything:

bash
substrat push --check

runs the layer rules and prints the full declared permission surface — every key, every role, every entity-grant shape, and the digest the promotion checkpoint compares — with no login and no network call. --json makes it a diffable CI artifact.

Environment settings are also declared once now: export envSpec from the same entry your permissions ride, and the push reads it from your code instead of from a second copy in package.json. If both exist and disagree, the push refuses and names the keys on each side — the silent version of that drift was a settings key nobody could ever set.

Smaller, and overdue: the CLI finally reads the control plane's structured error documents, so a refused push or promote says "the permission diff has not been acknowledged" instead of printing a wall of escaped JSON, and a validation failure lists the offending fields. Previews accept --allow-unserved-ui, and the inline-assets exemption now reads your import rather than a build artifact, so a UI that was correctly served no longer gets refused on a fresh CI checkout.

Every vertical is an MCP server now

Every vertical serves an MCP endpoint at /api/mcp, with zero setup: one tool per operation that declares an HTTP route, described from what the operation already declares — its summary, its input schema, whether it reads or writes. An agent connecting with a bearer token can do exactly what that token could already do with curl: the same routes, the same permission check inside every operation. It defaults on because it adds no reachability — it is a third rendering of the surface you already declared, beside REST and OpenAPI. An operation you would rather keep out of the tool list opts out with mcp: false; a paged read names its paging inputs so an agent walks the cursor instead of mistaking one page for the whole table.

The discovery half works end to end: an unauthenticated call gets a proper 401 challenge pointing at the resource metadata, which names the issuer — and the hosted issuer now accepts a client that identifies itself by URL, so an MCP client needs no registration step at all. Nothing to create, no secret to hold: the client's id is the HTTPS document describing it, fetched and validated with redirect and special-address protections.

The hosted issuer: Microsoft, BankID, and your own identity provider

The hosted OIDC issuer's sign-in options are now rows an operator manages from the dashboard, not configuration that needs a deploy.

  • Sign in with Microsoft (and Google and GitHub, from the same catalogue). The panel asks for the credential and shows you the redirect URI to register rather than asking for it. Two decisions per provider, because neither has a safe default: may this provider create accounts, and do you trust its email addresses — the second is what makes an admin-created user signing in with Microsoft land on their account instead of being refused.
  • Sign in with BankID. Swedish e-ID, animated QR and same-device link, over mutually authenticated TLS against BankID's API. Configured from the dashboard with the certificate pair; if the runtime cannot present one, the login screen simply offers no BankID button rather than a flow that cannot finish.
  • A custom OIDC provider, beside the catalogue: Keycloak, Okta, Auth0, or another issuer like this one. You give it a slug, a label, the issuer URL and a credential — never five endpoints typed by hand. Discovery resolves once, when you save, so a wrong issuer URL is refused while you are still in the form, and a sign-in makes no discovery fetch at all.
  • Per-client theming. The application that sends someone to the login, sign-up or consent screen decides how those screens look — colors, corner radius, logo, title — from an Appearance section in the client editor. The public theme read is sanitized key by key and answers identically for unknown, disabled and unthemed clients, so it cannot be used to enumerate the registry.

The issuer also deploys itself on every merge now, like every other vertical — which is why these features are live rather than waiting for someone to remember to push.

A connector for Fortnox: read the books, with nothing to lose

The first inbound connector reads a company's bookkeeping from Fortnox — accounts, cost centres, monthly debit/credit sums — and hands your vertical a neutral ledger page, leaving what the accounts mean to your vocabulary.

It deliberately holds no refresh token. Fortnox refresh tokens are single-use and rotating, so two concurrent refreshes race and the loser bricks the connection; this connector uses service consent with client credentials instead, minting an access token on demand from three static values. The cost is a one-time consent per company — and the dashboard now does that round for you: a Connect button on the Fortnox provider card, or a copyable, single-use, expiring connect link you hand to whoever administers Fortnox, no dashboard login needed on their side. The callback page names which company was attached, organisation number included, so a wrong-company mistake is visible on the spot.

Its first run against a real company found what only a live run could: the SIE4 export is code page 437, not Latin-1, so every Swedish letter decoded plausibly and wrongly — the decoder now reads the file's declared format and refuses a charset it does not implement rather than guessing. Worth knowing before you connect: the Fortnox customer needs an integration licence on their subscription, and the scopes must be ticked on the integration or the consent screen fails with an error naming neither.

Module code cannot write the platform's own tables

One thing changes behaviour. ctx.sql now refuses any statement whose write target is a _substrat_* table, on both the local runtime and the hosted one. Those tables are the platform's spine — the event outbox, the permission tuples — and a write to them forges history or mints authority. The rule always existed; what enforced it was a source lint that never ran on the hosted push path, so a vertical that never passed through this repository's CI reached production with the rule unenforced. Now the refusal is in the database seam itself, quoted identifiers and chained statements included, and it takes the whole operation down.

Reads stay open, deliberately — only the write's target is judged, so the blessed projection pattern still works:

sql
INSERT INTO my_timeline SELECT ... FROM _substrat_outbox

The engine seam is finished

Every engine now validates everything it hands back to you against the schema it publishes — invites, invoicing and metering joined this week, completing the set — so a vertical compiled against one engine version and running against a newer one gets an error instead of wrong data on a screen. The sharpest cases are why this matters: an invoice total whose summand drifted is a wrong number on a real document, and the invite engine's identifier hash is a column that must never leave — both are now held by a parse, not a habit.

A new lint rule will flag existing code. An engine may no longer read with a star: SELECT * in engine code is a boundary-lint violation (rule R8), because a star read publishes whatever columns the physical table holds today, which is exactly the version-skew hole the seam closes. A maintenance read whose row never leaves the engine can carry a reviewable allow-block.

And one declared surface changes shape. Five engine reads that declared a single entity while returning an unbounded array now declare and return what they always should have: a page. The invite engine's list and four metering reads (meters, entries, periods, period lines) answer Page<T> with a cursor, so the generated client and the OpenAPI document finally describe what arrives. If you called one of these over HTTP, you now get a page envelope where you got a bare array — walk the cursor. The in-scope functions beside them are unchanged: composing a fold inside your own transaction still gets the plain list.

Two more engines grew up alongside: the protocol engine's state machine is now declared rather than hand-guarded — same states, same refusals, but an app can derive "what can I do from here" from the engine instead of re-deriving status checks in a button's disabled — and all seven engines now emit a model.json, so a changed engine table appears in a reviewable diff instead of landing silently.

Delegation is bounded, public doors exist, and a masked copy works

Three additions to what a vertical can express, and two repairs underneath:

  • Assigning a role is bounded by your own authority. ctx.canAssign(role) answers whether you hold everything the role would confer — and tells you which permissions are missing when you do not. Without it, anyone allowed to manage members could hand out (or self-assign) a role wider than their own, and no review would ever see the widening. A record someone merely shared with you does not count toward what you can confer: a narrowed grant cannot be laundered into node-wide authority by way of assignment.
  • A public surface, properly. mountPublicSurface gives a vertical a surface anybody's browser may call — a widget, an embed — running as a service principal you declare, with the origin allowlist checked per request (preflights included, re-read live) and the refusal happening before any handler runs. It is the support widget's machinery, extracted from the demo into the host where the next vertical finds it.
  • A masked scope export is a working copy now. Pulling a scope with masking used to blank every personal field to [masked] — structurally valid, factually useless. It now writes deterministic, shaped fakes: an email is a real-looking address at a reserved domain, the same person reads the same everywhere (rows, event payloads, the search index), joins still line up, and the copy imports and runs. Free text and national identity numbers deliberately stay [masked] — a fake there would be a lie or a real person. The whole round trip is now driven through a real vertical in the test suite.
  • A scope backup is judged before any of it reaches SQL. A dump names its own tables, columns and schema text, and all three used to reach the database unexamined — on the CLI's pull and restore, and on the hosted restore path. Crafted names and appended statements are now refused up front, on every replay site, before the first statement runs.
  • Time in tests got honest. The local host's injected clock now decides grant and entitlement expiry — not just ctx.now() — so a scenario can advance a manual clock past a grant's expiry instead of sleeping through a shortened window. Relatedly, an instant carrying a UTC offset is normalised to Z at the parse: expiry is compared as text, and an offset-bearing timestamp used to sort wrong, letting an expired grant read as live. Instants now round-trip at millisecond precision, so the exact text you get back may gain a .000.

A push repairs its own installs

A vertical's provision hook runs once per scope, at install — so anything a newer version mints for itself there never reached a scope installed earlier, and the new code arrived depending on something that did not exist. That is no longer your problem to notice: every scope now records which version its provision hook last ran against, and the platform sweep reconciles any scope whose bound version has moved past its receipt. A push repairs its own installs; a repair that fails outright retries on the next pass. One case does not: when the repair itself succeeds but one of the scope's declared stores could not be created, the receipt is still written and the sweep moves on — the scope's health keeps reporting the missing store until you retry it yourself. The lever for that — and for any repair you want now rather than on the next sweep — is Re-run provisioning on the console's scope detail.

Look at your entity model

bash
substrat model view

renders your declared model — the ER diagram, a card per entity with its keys and erasable fields marked, the declared lifecycles — as one self-contained HTML file: no network, no login, nothing external. It exists for the design gate: a non-technical approver reading a diagram of their own domain is a better checkpoint than the same person reading prose about it.

The same rendering now rides the push. A pushed version carries its model.json, and the dashboard's new Model tab shows the entity model of the version an app is actually running, with a banner when production has moved. Existing deploys show an empty state until re-pushed with a current CLI.

ticket0 became a desk you can staff

The support-desk demo spent the week turning from a demonstration into a desk. It can be staffed now: Settings grew a Team tab — invite a colleague at a role, copy the one-time link, revoke a pending invite — and accepting an invite puts the person straight into the assignee picker. Assignment itself became real: a directory of agents, a picker in the conversation rail and one in every inbox row for triage passes, and an assignee that names nobody is refused instead of stored.

The daily verbs filled in. A conversation's priority can be set (the filter for it existed for weeks; nothing could ever write it). Tags can be read back and taken off, and the desk's tag vocabulary autocompletes most-used-first. A customer's rating reaches the agent who handled the conversation. Free-text search covers subjects and every message body, with a people search beside it — a match on an internal note never leaks the note to a customer. Saved replies gained edit, delete, and server-side placeholders: the reply renders with the conversation's facts filled in, and a placeholder with no value warns you before "Hi ," reaches a customer. Snoozed conversations wake on a timer instead of only by hand. And a conversation nobody will answer — spam, an abandoned widget session — can be closed without pretending it was resolved: closing stamps nothing, counts as no answered work, and actually removes the thread from the inbox.

The desk also learned to account for itself. A Reports screen answers response time (median and p90), backlog and its oldest untouched thread, CSAT, per-agent resolution counts, and the assistant's deflection rate — including what the assistant cost per resolved conversation, with a null drawn as a dash rather than a fake zero. The assistant itself is now governable from Settings: let it answer customers directly, or put it back to drafting for a human, reversibly — and the answers waiting for a person are listed instead of silently accumulating. Its retrieval got smarter about breadth: when one page ranks well twice, the section that actually answers is no longer dropped for the page's best-ranked one.

And the desk follows you around the platform now: the console and the dashboard both embed it signed in, so a question from either arrives attached to a person instead of an anonymous visitor.

The shop and the workshop sign in like everything else

Two more demos dropped their credential stores. The shop's storefront no longer owns passwords — sign-in is an ordinary OIDC round-trip, and moving to a real issuer is one environment variable; the anonymous browse principal stays, because a storefront must answer what someone who has not signed in may see. The migration also flushed out a real bug: a self-service shopper was silently minted a fresh account on every visit because the identity link was written under one provider name and looked up under another.

Handlebar, the workshop demo, had no login screen at all — it ran on a dev header that was also the only "login" anyone reading it would see. It now does the same OIDC round-trip, and driving the real HTTP path surfaced that its customer screen had never worked in a hosted install; it does now.

For hosted operators

  • Workers AI calls are priced now. The rate card carried four hand-picked models; everything else metered correctly and billed nothing, silently. The Cloudflare catalogue is now priced wholesale — 31 models, cross-checked against two sources — so a call that used to show $0.00 acquires a list price. Only new calls: lines already written keep their null.
  • The AI binding actually arrives now. A vertical declaring that it uses platform models never received the runtime binding — every link in the chain was individually correct and one hop never passed the flag. Fixed; a vertical already deployed needs one re-push with a current CLI to pick the binding up.
  • The platform's own relay no longer trips your outbound policy. A vertical with a declared outbound allowlist was refused by its own policy when calling the platform's email or connection relay — the relay's origin is on a different zone and was never yours to declare. It is now exempt, and metered as its own verdict, distinct from calls to third parties.
  • The Scrive connector requires its base URL. The testbed default is gone: a production credential sent to the testbed comes back as an indistinguishable 401. A deployment now names its environment; an unset base fails that connector's dispatch and nothing else.
  • Embedding the support desk needs one secret in the platform templates — the widget verification secret is now named in both environment templates, so configuring it no longer steers you toward pasting a live secret into a tracked file.

Also

  • The scaffold caught up with the platform. A project from npm create substrat now hands the host its declared operation inputs, so the host parses every invocation before your handler — the template was the last place still teaching hand-parsing. Its agent rules and playbook now cover all seven engines, the atomic-catch rule, and a symptom→fix table for the failures new projects actually hit; the agent plugin adds help, init and deploy skills, with the deploy skill rendering the permission table and the migration diff and then stopping — it never acknowledges a checkpoint itself.
  • The landing page leads with what you can build — the eight demo verticals, three of which compose no engine at all — instead of leading with the infrastructure argument.
  • The docs stopped underselling what exists. The pages claiming no connectors were built (which the support desk was faithfully quoting at visitors) now describe the shipped ones; engine pages document the parsed seam, the tenth protocol permission key, and the paged reads that actually ship; the invoicing engine's missing in-scope surface is documented as the design it is, not a gap; reference pages cover the invocation options for concurrency and idempotency, the model-usage seam, cursor-uniqueness rules for pagination, the auth composition, and the AI-binding declaration; the metering docs state both bounds on backdated usage; and the dashboard's architecture pages now say plainly which tenant-isolation property is server-enforced and which is not.
  • substrat.net ships a Content-Security-Policy, derived from each build — including the origins of any support desk a page embeds, which the first deploy proved by blocking its own widget.
  • Operational failure records now carry the app version, so a failure can be joined to the push that introduced it — the first brick of the observability work in progress.

Released

PackageSpan
@substrat-run/contracts0.94.0 → 0.98.1
@substrat-run/kernel0.94.0 → 0.98.1
@substrat-run/adapter-sqlite0.94.0 → 0.98.1
@substrat-run/adapter-cloudflare0.94.0 → 0.98.1
@substrat-run/vertical-host0.94.0 → 0.98.1
@substrat-run/vertical-auth0.10.0 → 0.12.1
@substrat-run/control-plane-api0.94.0 → 0.98.1
@substrat-run/contract-tests0.94.0 → 0.98.1
@substrat-run/oidc-rp0.6.0
@substrat-run/model-emit0.8.9 → 0.8.15
@substrat-run/model-providers0.3.0 → 0.4.1
@substrat-run/boundary-lint0.2.1 → 0.4.1
@substrat-run/psl0.2.4
@substrat-run/dev-issuer0.1.8 → 0.1.13
@substrat-run/engine-workorder0.10.2 → 0.10.6
@substrat-run/engine-booking0.7.1 → 0.7.5
@substrat-run/engine-invoicing0.9.10 → 0.9.14
@substrat-run/engine-protocol0.11.11 → 0.12.3
@substrat-run/engine-invites0.6.0 → 0.6.4
@substrat-run/engine-absence0.5.6 → 0.5.10
@substrat-run/engine-metering0.5.0 → 0.5.4
@substrat-run/connector-scrive0.13.10 → 0.14.3
@substrat-run/connector-fortnox0.2.0 → 0.4.0
@substrat-run/cli0.26.0 → 0.27.0
create-substrat0.8.3 → 0.8.6

@substrat-run/connector-fortnox is new on npm this week — the Fortnox connector above. The protocol engine's minor bump is its declared state machine; boundary-lint's two minors are the new engine star-read rule and the push-gate integration; the CLI's minor is the push gate, push --check and model view.

The hard parts, hosted.