Skip to content

Week 33 · 10–16 August 2026

This week was mostly about the seam between a vertical and the outside world. Connecting a third-party provider became something you do from the dashboard or from your own vertical's screen, the credential is checked against the provider before it is stored, and you can read what the connection did and why a delivery failed — a refused request is no longer retried for two days. A signature request now says how each party authenticates. Two engines joined the catalogue, one for approved absence and one for metered usage, and a hosted vertical now declares which hosts it calls. And the builder studio — a chat that interviews you and builds a vertical against the real gates — opened to teams holding an entitlement.

Integrations you can connect, verify and read

Connecting a provider such as Scrive used to mean handing your credential to platform staff out of band, and then trusting it. Now it is a screen: the per-app Settings → Integrations tab, or the account-level Integrations page, takes the provider's credential and stores it sealed. A vertical that needs a provider says so in its manifest —

json
"substrat": { "requires": ["scrive"] }

— and the dashboard shows it as required by this app. That is an offer, not a gate: a signing request dispatched before the connection exists settles as pending and delivers the moment it is connected. A vertical can also take the credential on its own admin screen and return it as a harness effect; the platform seals it and never writes it into the scope's data, so it appears in no export or backup. Rotating a credential keeps the connection's id, and with it every grant the connection holds. And a grant made to a connection is now delivered to every install — including one provisioned after the grant, which used to ship without its return path and fail every reconcile.

Connecting means verified, not stored. The candidate credential is sent to the provider before anything is written. If the provider refuses it, nothing is stored and you see the provider's own words; a refused rotation leaves the live credential untouched. If the provider is unreachable, the credential is stored and reported as unverified — an outage is not a refusal. Every result names which environment it asked, because a production key sent to a testbed fails exactly like a typo. That last fact found a bug in the platform's own deployment: the hosted control plane had been sending production Scrive credentials to Scrive's testbed. It is fixed, and stated explicitly per environment rather than defaulted.

Opening a connection shows what it is: the provider account it acts as, the stored credential with identifiers whole and secrets masked to their last four characters, the live grants it holds, and a Delivery attempts list — every request the platform sent on its behalf, with status, attempt count, and the provider's error verbatim. The provider's own archive is one switch away, marked to show which documents came from this app. Two things a vertical gets from the same work: ctx.platformRequests(filter) is the read half of ctx.requestPlatform, so an operation can read the outcome of its own intents instead of querying a private table; and a platform that cannot store secrets answers 503 naming the missing key, not a bare 500.

One thing changes behaviour. A provider's refusal — any 4xx — now fails the request on the first attempt, carrying the provider's message. Before, every error was treated as transient and retried for two days; a production tenant had three signature requests sit at a hundred attempts each on the same permanent error, with nothing to show the user. Outages (5xx, 408, 429 and friends) and network failures are still retried.

Signature requests choose how a party authenticates

One thing changes behaviour. A party on a signature request now carries an authLevel: basic (the provider establishes control of a contact address) or strong (a national eID). The default is basic. Until now the Scrive connector sent every external party to BankID unconditionally — Scrive's word, not the engine's — and since the request could not satisfy what BankID asked for, every document it ever sent was refused. If you relied on that default, say authLevel: 'strong' on the party, or set defaultAuthMethod: 'se_bankid' on the connector to keep the old behaviour deliberately.

strong now actually starts a document. Probing the testbed showed that Scrive checks the personal-number field is present, not filled — the signatory completes it during the BankID ceremony — so the sender never needs to know a personnummer, and none reaches the platform. When Scrive refuses a document, every reason is reported at once rather than the first one; an operator no longer fixes one problem to meet the next.

One caveat is stated on the connector's page rather than discovered: a party still carries no contact address, so a document with a real counterparty is refused at start (visibly, and journalled), while a document whose only party is the author starts, reports sent, and invites nobody. The contact carrier is the next piece of this work.

Two new engines: absence and metering

@substrat-run/engine-absence is the approved-absence ledger, extracted from the Meridian demo now that a second vertical wants it. Every entry is append-only over an opaque subject — you own the employee directory, the engine never dereferences it — and a balance is a fold over entries as of a date, never a stored counter. Only an approved request can book time; cancelling an approved absence writes a compensating reversal in the same transaction; a per-leave-type floor is enforced at decision time, and a negative floor models advance leave with no code change. availability() returns coverage only; weekends and holiday calendars stay yours. A daily expiry schedule is declared by the engine. Meridian's absence:* permission keys are the same strings as before; its absence events move to the engine's absence.* vocabulary.

@substrat-run/engine-metering is the billable-usage ledger. A meter has a fixed kind and unit; entries are append-only and idempotent by a key you choose — replaying the same key with the same quantity returns the existing entry, the same key with a different quantity throws, because that is the bug the key exists to catch. Counters sum signed deltas (a correction is a compensating entry); gauges take the maximum in a window and carry their level across silent ones. Closing a period freezes its lines and sets a horizon nothing can write behind, and emits metering.period-closed with unpriced lines — your vertical maps meter keys to rates and feeds invoicing, the same way timesheet periods already do. Four permission keys: metering:read, metering:record, metering:configure, metering:close.

Hosted verticals declare the hosts they call

One thing changes behaviour. A hosted vertical's outbound network policy used to be none: every fetch() from its worker passed through untouched, under the platform's own account. Now the vertical declares what it calls —

json
"substrat": { "outbound": ["api.example.com", "*.stripe.com"] }

— hostnames or *.-wildcards, never an apex. The list is enforced per request at the egress seam and every verdict is metered, so an exfiltration attempt is a chart rather than a guess. The default is [], and the CLI always sends the field: a version pushed with the current CLI that makes a direct third-party call it has not declared is refused. Most verticals need nothing — connectors run platform-side, mail rides the platform's sender, and calls to other verticals ride the router. A version pushed with an older CLI has no declaration and runs unenforced but metered until its next push, so least privilege arrives version by version and never as a fleet outage. The policy follows the code that is actually serving, not the version an install is bound to, so a promote never enforces one version's list against another's code.

The builder studio

The studio at builder.substrat.net is a chat that builds a vertical. It interviews you one question at a time, writes the concept document you approve, then scaffolds and iterates against the same gates every vertical in the repo runs — typecheck, boundary-lint, the permission diff, the scenario test — and commits every turn. Which skills the model is loaded with is decided by facts about the working tree, never by the model's own claim, and the stepper at the top shows exactly that. A red gate is fed back into the next turn and repaired a bounded number of times; a turn the step ceiling cut off says so instead of reading as finished. You pick the model — Claude, Qwen, Cloudflare Workers AI, or any OpenAI-compatible endpoint — and each row says whose infrastructure it runs on. Token spend is recorded in the studio's own metering ledger (the first consumer of the engine above) and priced on a rate card.

Access follows the team, not an email list: a team holding the builder entitlement can open the studio, and a trial is a grant with an expiry. The entitlement is granted by platform staff for now. This is early software, and its first real builds found the rough edges a first week does — the file pane now reads from a snapshot without waking the sandbox, the stop button stops, installs are run mechanically rather than hoped for, and a quota error is no longer reported as an invalid key.

Deploying: monorepos, provenance, and the runtime baseline

A vertical that lives inside a monorepo can now be connected: the generated deploy workflow takes the package directory, and so does the CLI —

sh
substrat init --ci github --path apps/helpdesk

— with the dashboard's connect form gaining a Directory in the repo field and deriving the app's slug from that directory. One connected package per repository, still.

Every pushed version now records where it came from: a git repository, commit and branch when pushed from CI, or cli from a terminal, shown on each version row in the dashboard. Versions pushed before this stay untagged.

One thing changes behaviour. The platform's runtime baseline — the compatibility_date given to every vertical that declares runtimeNeeds — moved from 2025-01-01 to 2026-06-01, and is now kept within six months of today by a test. Those verticals pick the new date up on their next push, a forward move for all of them. A push whose otherwise-ignored wrangler.jsonc pins a date newer than the baseline is now refused with the remedy named, rather than silently moving a live worker backwards.

Two smaller things. A vertical with genuinely no permission surface can say so — definePermissions({ modules: [], roles: [] }) — and pass the push gate with a stub PERMISSIONS.md, distinguishable from a forgotten declaration. And @substrat-run/vertical-auth with its dependency @substrat-run/oidc-rp are published, so a scaffolded project's authenticatedPrincipal has something to install rather than a pointer to a private package; every package has a README, and the reference section gained pages for vertical-auth, psl and create-substrat.

Dashboard and console

  • The team is in the URL: app.substrat.net/<team-slug>/…. Old bookmarks redirect onto your pinned team; a link to another team's page switches you before anything loads.
  • Both apps refresh themselves — on tab focus, and on a slow poll while visible — so an install finishing or a teammate's invite shows up without a reload. Nothing polls while the tab is hidden.
  • Back works in the console: navigation pushes history, so Back returns to the list instead of leaving the site.
  • In the console, bound scopes get bulk selection — move installs to another lineage or retire them in one pass — and the Members view is real: the staff roster with grant and revoke, attributed.

For hosted operators: an entitlement key that could never match. A vertical pushed from a workspace gets a slug like t-…/crm, and the entitlement granted at install was derived from that whole slug — a value the manifest's key format cannot express, so the granted and required keys could never agree. The mismatch fires the first time entitlements are projected onto a scope — a reconcile or re-provision, possibly months after install — and every gated operation is refused. The key is now the slug's last segment, repaired on the next install with no re-push. A denial also says what the tenant holds beside what is required, with lapsed grants marked, so a near-miss reads as one.

The auth-server demo is a first-class install

The standalone OIDC issuer demo behaves like any other hosted vertical now. PUBLIC_ORIGIN is an optional pin: left blank, the issuer derives its issuer and every discovery URL from the hostname each request arrived on, so a custom domain that is not yet routable can no longer be advertised as an issuer that routes nowhere. It implements the platform's export and delete verbs, so retire-with-backup works and an install can be moved between lineages with its data. For any script that never implemented delete, retiring — once a backup has been taken or explicitly declined — now tombstones the directory row and reports the bytes as stranded instead of refusing forever.

Also

  • The integration detail re-reads the connection row after a probe, so Test connection no longer reports success beside the error it just cleared.
  • Serving: a missing asset recovered from a version's archive is refused loudly if its bytes do not hash to the manifest, never served on trust.

Released

PackageSpan
@substrat-run/contracts0.56.0 → 0.65.0
@substrat-run/kernel0.56.0 → 0.65.0
@substrat-run/adapter-sqlite0.56.0 → 0.65.0
@substrat-run/adapter-cloudflare0.56.0 → 0.65.0
@substrat-run/vertical-host0.56.0 → 0.65.0
@substrat-run/control-plane-api0.56.0 → 0.65.0
@substrat-run/contract-tests0.56.0 → 0.65.0
@substrat-run/cli0.21.0 → 0.24.0
@substrat-run/engine-absence0.1.0
@substrat-run/engine-metering0.1.0
@substrat-run/engine-workorder0.3.54 → 0.3.63
@substrat-run/engine-booking0.1.51 → 0.1.60
@substrat-run/engine-invoicing0.5.12 → 0.5.21
@substrat-run/engine-protocol0.5.14 → 0.6.1
@substrat-run/engine-invites0.1.1 → 0.1.10
@substrat-run/connector-scrive0.3.0 → 0.8.0

Two packages are new this week: @substrat-run/engine-absence and @substrat-run/engine-metering, both at their first release. The protocol engine's minor bump is the authLevel field above. One of the kernel's is canStoreSecrets, which an adapter outside this repo must now answer; everything else in the spans is additive.

The hard parts, hosted.