Week 37 · 7–13 September 2026
This week the dashboard learned to answer the questions an observability vendor cannot: not just how much traffic an app served, but whether the Tuesday schedule ran, whether the receipt that should land every day did, what a push changed, why a given invoice exists, and which declared event nothing has ever produced. Most of it reads facts the platform already recorded; what is new is that a screen finally shows them — and the app page was rearranged around them, which is the one thing here that changes where you click. The hosted issuer gained the things a real identity provider needs: a person can connect a second sign-in method to the account they already have, a relying party can pin which methods it accepts, and a failing sign-in leaves a record an operator can read. Signing out of a hosted vertical now actually signs out. A connector for Planima arrived, a vertical can start a provider consent round itself, and the Fortnox consent callback — which had failed on every hosted attempt — works. And there is a new demo, tock, for turning delivered files into numbers someone can defend months later.
The app page is four tabs, and Observability is a team page
One thing changes behaviour. An app's dashboard page is now Overview · Deployments · Data · Settings. The tabs that used to sit beside them are sections of those four: Schema and Previews live under Data, Permissions under Settings, and the update comparison and schema history sit on Deployments beside the Update button they answer for. Two things moved up to the team level, where they can show what no per-app tab could. Audit is an item in the left menu covering every app on the team, with an app filter, and it now lists the entries that name no app at all — role changes, entitlements. Observability replaces the old preview Analytics page: one chart on top, one time range for the whole page, and an app chip that decides the mode. With every app selected you get one line per installation and a worst-error-rate-first table; with one app you get the same chart at that app's grain, then Traffic, Logs, Events, Schedules and Flow under the page's single window. Old links keep working — the retired tab names and the app's observability path redirect, and the redirect replaces the entry rather than adding one, so the Back button never lands on a dead URL.
The grain is why the page can exist. Until now the dashboard bucketed traffic per deployed script, and one vertical's script serves every team that installed it, so a team reading its own app's numbers got the fleet's. The new series carries the installation as a dimension, so an app running a vertical someone else publishes can finally read its own traffic and logs instead of a sentence saying they stay with the builder. The vertical author's half is one line: mount invocationLog() from the kernel before any route, and each request leaves a tenant-stamped line the installer's page can find. The path is logged without its query string, because an issuer's callback carries live credentials there.
The Apps list gained a Needs attention panel: one health verdict per app, worst first, and only the apps that are not fine — a clean fleet is one green line. An app no sweep has reached is listed as loudly as a failing one, because nothing having checked it is not the same as nothing being wrong with it. An app that declares nothing to sweep reads as fine and says so; an app the read could not cover becomes a footnote under the panel, never a row nobody can act on.
Schedules, freshness and sweeps: the things that fail by silence
A scheduled sync's failure mode is silence, and until now nothing recorded that a sweep pass happened at all. Every sweep unit now leaves a durable record — each connection polled, each schedule that fired or was skipped as not yet due — and the dashboard reads it back in three places. Every connection on the Integrations pages carries a recent-runs strip: one tick per pass, green for a pass that found nothing to do (previously recorded nowhere), amber for a connection that is bound and nothing polls, red with the error in the tooltip. The app's Observability view opens with a Schedules panel: every schedule the running version declares, with a verdict — On schedule, Overdue, Never run, or No sweep data — and a sentence that says what the verdict rests on, "Due 3h ago — last ran 27h ago, every 24 hours." Overdue is judged against the cadence plus one sweep window, additively, so a daily schedule's owner is not told a day late and a five-minute one does not false-alarm. When no sweep of any kind has reached the app in two windows, one line says so and the per-schedule verdicts are withheld: a stopped sweeper is never the schedule's fault.
The third place is a declaration you can make now. A module manifest may carry freshness expectations:
freshness: [{ eventType: 'receipt.landed', within: { hours: 24 } }]An event type the module neither emits nor consumes is refused at push, where the mistake is readable, rather than reading as permanently stale forever. The evaluator runs where the evidence lives — inside the scope, over its own outbox — and the panel renders the sentence this whole feature exists to produce: "No receipt.landed 26h and counting — expected within 24h." A fresh expectation still shows its evidence, and a brand-new install reads as never seen rather than opening red. Windows are in hours on purpose: a freshness window under an hour is a schedule wearing a disguise.
Nothing appears until you re-push with a current CLI. Declared schedules and freshness expectations travel on the deploy manifest now, and a version pushed before this release reads as declaring none — the panel hides rather than nags. For a hosted vertical the same push carries the adapter whose sweeper reports its outcomes, so the chain is one re-push per app. On the console, Operations gained a Sweeps view over the whole fleet, so "the Tuesday cron never ran anywhere" reads as one glance rather than a per-tenant tour.
Releases: what each push did, and which failures came back
A vertical's dashboard page has a Releases panel: every version as a release, with when it was pushed and went live, where it runs — production, scopes pinned to it, scopes following production, kept separate because "is anyone still on the broken one" reads differently for each — its failures in the 90-day record, and its traffic. The panel opens with a 24-hour traffic chart carrying every push and go-live as a marker, so "did this push break my sync" is a picture. An empty bucket is drawn as zero rather than skipped, so an outage cannot render as a narrower peak; errors draw inside their bucket's bar so a tall error bar cannot read as more errors than requests; and a push that produced no traffic still gets its line, which is the most interesting push on the chart. Beside it, an Update comparison puts the running version against the one an update would move you to — requests, error rate, CPU percentiles — and renders only when an update exists. A Schema history card lists when each migration was applied to the scope, a fact the platform has written since day one and never let anyone read.
Failures now group into issues. Every operational failure record carries the shape of its error — who refused, and the platform's own error code — and the console's Operations page gained an Issues view: one row per failure shape with a count, first and last seen, and a lifecycle. Resolve it, ignore it, reopen it; a fresh arrival on a resolved issue flips it to regressed and the row names both versions — seen under Y · resolved under X — came back. A fresh arrival on an ignored issue changes nothing, because ignoring is a verdict about the future. A builder gets the same compression on their own vertical's page as a Recurring failures panel, derived from their own rows and shown only when grouping actually compresses something.
Underneath all of this, every event now names the operation that emitted it and the version the emitting code was deployed as, stamped by the kernel so a vertical can neither forge nor suppress either. readHistory returns both as operation and version, each nullable as a fact: a consumer-emitted event records no operation, and a row written before the columns existed records neither. The version is injected into every deploy as a SUBSTRAT_VERSION_ID binding, which brings the one refusal in this section. A pushed manifest may no longer declare a binding whose name begins with SUBSTRAT_. The prefix is the platform's, and a vertical declaring its own SUBSTRAT_VERSION_ID would have forged the stamp every release fact above is joined on.
Two honesty fixes found by looking at production: the comparison card used to render "0 req · no traffic" for an app whose vertical another team publishes — a confident zero about numbers it was not allowed to see — and now renders the version pair, which is knowable, with the traffic marked unknown. And three self-hiding panels used to compose into a tab that looked like nothing had shipped; being current now says there is nothing to update to, and an unreadable schema history says the history arrives with the next push instead of hiding.
One record's story, and why an event exists
Click a row's id in an app's Data tab and you get that record's history: every event, with the payload, who acted, the permission that authorized it and which grant if a shared record was the reason, the staff member behind an impersonation, the PII class, the operation it came from, and the version. Three nullables stay apart on the screen, because flattening them is what a hand-rolled query does: payload erased is a shred that kept the row, authorization unrecorded is a row older than the recording, and no permission checked is a different answer from either. An empty history states the entity type and id it looked under, so a vertical whose events name a different type than its model reads as a mismatch rather than as "nothing ever happened to this record". A record keyed on something other than id gets the same link, and a composite-keyed one deliberately gets none — a history reads one entity reference, and offering the link on one column would answer wrongly.
Every event in that history has two more buttons. Why? walks the causal chain backwards — this invoice exists because that timesheet closed, because the Monday sweep ran — and What did it do? walks it forward, as a tree of the consumers each event reached, whether each threw, how many attempts, and what those consumers emitted in turn. The backwards walk is possible because the outbox now records why an event exists: an event emitted while a delivery was in flight carries the id of the event it reacted to. Nothing is invented — an event raised directly by an operation records no cause, rows written before the column keep a null that means unrecorded, and the walk states which of four endings it reached rather than presenting a fragment as the whole story. No timings appear anywhere, because nothing in the platform emits a span for an operation yet, and a column of plausible numbers would be worse than an honest absence.
An Events explorer sits beside them: group this app's own events by type, operation, actor, version, entity type, PII class, or any top-level payload field, narrowed to one type and a window, and count them. Each bucket carries when it was last seen. An erased payload is counted apart and never grouped — grouping by a payload field over shredded events would otherwise fold redacted history into a "no value" bucket and present the distribution as complete. A non-kernel app, or one last pushed before the explorer shipped, gets a sentence saying it publishes no event stream rather than a transport error in red.
The Flow view draws the declared app: request path and schedules, then modules, then event types, then the outside — providers and outbound hosts — with emit edges down and consume edges dashed back up. Each event node carries the count the scope has actually recorded, so a declared type nothing has ever produced is drawn with a dashed outline, and one recorded thousands of times and not since February is amber with how long it has been. Those two are kept apart in the data: a path that never ran may not be built yet; one that stopped means something changed. A findings list beside the map names the gaps — never emitted, nothing to handle, a provider declared and not connected, a connection that needs reconnecting — and refuses to blame a handler for a type that stopped, since the handler is the one component behaving correctly. A per-operation health table joins events per operation with refusals per operation from the denial log, unioned rather than joined: an operation refused at its first line emits nothing, and it is exactly the one somebody is failing to call. The denial summary can now bucket per operation to feed it.
The Model tab gained Field coverage: how many declared fields any operation declares it returns, the never-returned tail grouped by entity, and the erasable ones among them called out — personal data a version stores and never hands back is a retention argument, not just cleanup. The copy claims declarations, not behaviour: a field on the list is named by no operation's declared output, which is verifiable, rather than "nobody reads this", which is not.
All of these read facts a push now carries — declared events, declared output shapes — so they appear for versions pushed with a current CLI and say so for older ones. Three kernel helpers came with them, beside readHistory: facetEvents, walkEventCause and walkEventEffects, each sanctioned for the same reason it is — the value is in the endings and nullabilities a hand-rolled query collapses.
The hosted issuer: a second sign-in method, per-client policy, and a sign-in log
Someone who signed up here with a password and later clicks Continue with GitHub was refused with account not linked and nowhere to go. The refusal is right — an address at an upstream is not by itself permission to become whoever holds it here — but the way past it now exists: connect the provider from inside a session you already have. The account keeps its id, so sub goes on meaning the same person to every relying party that stored it. The "not an administrator" page, the only page an ordinary person of the issuer ever reaches, became Your account carrying that panel instead of a dead end. An administrator can take one method away from a user, never the last one.
ACCOUNT_LINKINGdecides issuer-wide whether an upstream sign-in may claim an existing account by address:link(the default, and what every install already did) orblock, which leaves only the deliberate connect above. There is no "keep them as two accounts" mode, and the reason is recorded: the issuer's own user table resolves an address to one row.- A relying party can pin which sign-in methods it accepts. A
signInobject in the client's metadata —{ "providers": ["microsoft"], "password": false }— sends that client's users to Microsoft alone while the next client keeps every method. Enforced at the authorize endpoint against the method the current session was actually established with, not by drawing fewer buttons. A client narrowed to exactly one provider skips the login screen entirely. A bug in the first cut sent such a client's users in a loop between the provider and the callback; fixed the next day, with the test that had been missing. - Sign in with Supabase, as a named catalogue entry over the custom OIDC door — the one fact nobody can guess is that a project's issuer is the project URL with
/auth/v1on the end, and the entry says so. A project still on the legacy shared JWT secret cannot be a redirect upstream at all, so a second door accepts a token such a project's own app already holds, verified with the care that secret demands: the project's public anon key is signed with the same secret, and only theroleclaim tells them apart. - A client can be allowed to sign people out. RP-initiated logout was refused for every client because two columns nothing ever wrote — whether end-session is enabled, and the post-logout redirect list, which is separate from the sign-in callbacks — are now in the Applications form.
- A failing sign-in leaves a record. Every federated round trip writes both ends — the authority the person was sent to, and the callback's outcome with the upstream's own sentence about it — into the issuer's own store, where the admin console and the dashboard's Data tab read it. A started row with nothing after it is the most telling shape: the person never came back, so the refusal happened on the provider's screen. Nothing from the authorization URL's query — the state, the PKCE challenge — reaches a row.
The admin console itself was rebuilt as a left-nav app with a URL per screen: a user detail page (sign-in methods, sessions to revoke, ban with a reason and an expiry, and a lever to mark an address verified, which is what an administrator-created account needs before a provider can attach to it), an application detail page, a provider detail page, and a BankID settings page. A pasted link survives the sign-in it triggers. The console runs on Geist, self-hosted, and the relying-party screens — login, sign-up, consent — fetch no font file at all, held by a test rather than a comment. The console is now a row in the issuer's own client registry, so an operator can theme it and narrow its sign-in methods like any application, with a refusal for any policy that would lock every administrator out.
Signing out signs out, and the profile arrives even when the ID token is bare
Signing out of a hosted vertical used to land on the issuer's Confirm logout page, because the vertical never kept the ID token the login round trip had verified and so could only ask for an anonymous logout — which a spec-faithful provider must confirm. The relying-party provider every hosted vertical composes now keeps that token in its own cookie, scoped to the logout path, and hands it back as id_token_hint, so the issuer verifies the request and redirects straight through. The platform's own apps got the same fix. Two conditions stay at the issuer and no client change substitutes for them: the client must be allowed to initiate logout, and the app's origin must be registered as a post-logout redirect. The first sign-out after those are set still shows the page — the token minted before then carries no session id — and the next sign-in puts one there.
A strictly conformant provider puts email and name in the UserInfo response rather than the ID token, and the relying party only ever read the token. The failure was silent and looked like something else: the login succeeded, the session was real, and it carried no address — so a vertical resolving a local account by address derived one from sub instead, and the person arrived authenticated and unrecognised, every screen answering 403. UserInfo now fills what the ID token left out, never overwriting a signed claim, refusing the login on a sub mismatch, and degrading rather than failing on a transport problem. The email_verified claim is carried through to AuthSubject as emailVerified — true, false, or absent when the issuer said nothing — and nothing acts on it yet. And the four member-invite routes three demos carried verbatim are one export now, mountInviteRoutes from @substrat-run/vertical-auth.
Connectors: Planima, a consent round your vertical can start, and Fortnox
A connector for Planima, the Swedish planned-maintenance service, reads a maintenance plan — organisation, facility, building, component, and the costed, dated actions hanging off them — on a poll, and lands it through the consuming vertical's own operation. Poll-only and read-only: a Planima token carries the full access of the person who minted it and there are no scopes to narrow it with, so the one control is which user mints it, and a read-only token costs nothing. Prices arrive as floats with no currency anywhere in the API; they cross the seam as exact decimals in a currency the binding declares, SEK by default. Four provider facts each have a test because each fails silently: the bare token in the header, the page-size cap the server applies without error, the rate limit, and the rolling window that is part of the sync's identity. It is built against the published API document and has not yet run against a live account; the docs page leads with that.
Pressing Test connection on it answered "Couldn't reach the provider", because the connector had a door in the dashboard and no registration behind it, and every gate was green. The rule is written down and mechanical now: a registered connector always carries a probe — the cheapest authenticated call that names the account, because the operator's question is "does this token see the customer I meant" — and a door with no registration behind it fails the build.
A vertical can start a provider consent round itself. Fortnox consent is per company with no bulk grant, and the people running those rounds work in the vertical with no dashboard account. An operation authorises the act and returns only a provider and a subject reference; host code asks the platform for a link with requestConnectUrl and redirects to it. Consent, exchange, sealing and the upsert all stay on the platform origin that owns the provider's one registered redirect — what crosses the seam is a link, never a credential, which is what makes it safe to hand to a third-party vertical. Fortnox connections now key on the company, so a bureau's fleet of client companies coexists instead of each consent silently replacing the previous company's credential.
And the Fortnox consent callback works on the hosted dashboard. Every hosted round had failed at the exchange step with a generic page: the callback handed the connector the bare global fetch, which the Workers runtime refuses when called with any other receiver, and which Node and every test pool accept. The runtime's fetch is now one kernel export, globalFetch, and a source check refuses the bare global handed on in any spelling. If you write a connector, hand it globalFetch.
A new demo: tock, files into numbers you can defend
tock is a workspace for turning delivered files into numbers someone can defend months later. You declare what a file is supposed to contain, tock reports what actually arrived, and a correction lands as a new run beside the old one rather than on top of it — so "what did we report in April, and on what basis" stays answerable after the numbers change. A run moves received → profiled → mapped → counted; a counted run is immutable and no run is ever deleted. It went from a concept to a driven browser loop in the week:
pnpm --filter @substrat-run/demo-tock dev
# issuer :8879 · api :8880 · web :5280Drop a file, see the inferred types and how many sampled rows have no value — the pane says in as many words that none of it is authoritative, because the server stores the bytes and reads them again — declare a schema, map it, count it, read the report. It reads the files that already exist: any delimiter, sniffed outside quotes; JSON lines; nested JSON flattened to dotted paths, with an array kept as one field rather than exploding into a field per index. A source can hold several kinds of record, told apart by an ordered list of discriminator fields, with a variant being a prefix of their values; the file proposes its own kinds from a sample, a kind nobody declared is kept and reported rather than dropped, and per-kind observations finally separate "absent because this kind does not carry it" from "absent because it went missing". Output shapes and a mapping — a correspondence table, no expression box, and there will not be one — mean a producer's rename stops breaking numbers: the day after the rename counts into the same shape as the day before, and the run records which mapping version made each number. Adding a field shows what history it has instead of asking for a default, with the count of older rows that have none — the argument against invented defaults is easier with the number on screen. Counting finishes across passes, so a big file is no longer one invocation, and a half-counted run is never the report's answer.
Three things the demo surfaced along the way are worth knowing beyond it. The migration checkpoint caught two faults in an approved model that re-reading the TypeScript had not. The planner does not diff CHECK constraints, so widening an enum on a shipped table needs a hand-written rebuild. And Vite's string-shorthand proxy rewrites the Host header, which sent every demo's OIDC callback to the wrong port — see the scaffold section.
ticket0: the desk sends mail, answers in context, and signs up substrat.net's beta
- A public reply on an email conversation becomes mail. The relay runner walks the pending outbound reads that have existed since the desk did and hands each message to a provider behind a seam — Resend first, selected by a
RESEND_API_KEYsecret. A desk with no provider leaves the message pending rather than stamping it delivered. On Workers the sweep hangs off the reply request that created the work. - An inbound mail's attachments leave a record instead of vanishing: an internal note on the conversation names every file, its type and size, and says plainly the bytes were not stored — so an agent stops telling a customer who is sure they attached something that nothing arrived.
- A customer writing into a closed thread gets a follow-up, not a 409. Closed stays terminal; the message opens a new conversation for the same contact that names the one it continues, and a widget session is re-pointed under the same token. The widget also stops rendering server error text at visitors.
- Talk to a human is a route. It used to post a sentence through the ordinary message path, which retrieval answered with whichever page mentions people. It now records the ask, acknowledges it as the desk rather than the assistant, and notifies whoever holds the conversation — or every agent when nobody does, which is also where the assistant's own escalations had been going silently. A visitor who types "can I speak to someone" is routed the same way.
- The assistant reads the conversation it is answering in. A second message in a thread used to be a first message from a stranger. The prompt now carries the public messages the customer actually saw — never internal notes — and retrieval gains a rung that bridges this message's best term to the topic it continues. A first message costs exactly what it did.
- The widget's routes are rate-limited, per embedding origin for session minting and per session token for messages, with a 429 that carries
Retry-Afterand that the widget's poller honours. The desk's business hours reach the visitor verbatim, replacing a hardcoded line every desk used to promise. - A documentation source carries a refresh hook — a per-source token, sent as a header — and the docs deploy presses it, so the desk's copy of the docs no longer goes stale until someone remembers a button. The support bubble is on every page of substrat.net now.
- The inbox gained a Tag facet and walks its pages instead of stopping at one, the top bar ends in an account menu with a sign-out that finally has a way in, and the desk's own assistant is no longer offered as an assignee.
- substrat.net opened a private beta, and both signup lists — the invite request on the landing page and the changelog's email list — post to this desk rather than a form service: an origin allowlist, an erasable column, an event per state change, double opt-in, a per-address resend throttle and an hourly ceiling on new addresses, and a closed membership oracle so an address cannot be probed one at a time.
Engines and contracts
Two inputs got stricter. @substrat-run/contracts gained calendarDate beside instant: a YYYY-MM-DD day with no time and no zone, checked for a real month and day, never to be compared with or coerced into an instant. The absence engine adopted it for every date it takes and answers, so 2026-13-45 is refused now where it used to produce a row, and an absence row somehow holding an impossible date would fail the seam parse on read. The one absence adopter in the repo did the same at its own door.
- An empty completion or export takes a currency.
completeWorkOrderandinvoicing/exportaccept an optionalcurrencyto label the one total the lines cannot label — a completion or a basis with no billable lines — instead of inventingSEK. The lines still win when they exist, and a declared currency that contradicts them is refused rather than ignored. - Listing invitations no longer writes. The invites list used to expire overdue rows on the way past, without an event. It now renders the effective state instead; an overdue invitation still reads
expired, withsettled_atnull until a write path meets it, andeffectiveStateOfis exported for a vertical folding the list into its own read. definePermissionschecks its keys. Hand it the sameas constarray you already givedefineOperationsaskeys, and it throws at module load if the array and the registered modules disagree in either direction;PermissionKeysOfreads the literal union back. A vertical composing engines lists every key its engines bring, which is the vocabulary an operation may name, not a claim of ownership.planMigrationreads a journal set. A vertical composed of surfaces ships one concatenated migration list built from several journals, and the counter belongs to the vertical: pass{ journals, surface }and the next version is the highest seen anywhere plus one, a table another surface created is not re-created, and a new table is refused unless you name which journal itsCREATElands in.- An event id is minted from the operation's instant, not the wall clock, with a floor seeded from the scope's own outbox on every open — so a restarted host, or a clock stepped backwards, cannot mint an id underneath rows already stored and hide them from anyone paging by id. The kernel exports
createUlidandulidTimefor a writer that needs its own floor. - The clock seam says which host honours it. The pure SQLite host takes a
clockand judges grant expiry, entitlements and schedules against it; the Cloudflare host declaresclock?: never, because the reads happen inside the Durable Object where an option cannot reach. Passing one there is a compile error now rather than a clock that moves some of the host's judgements and not others. A contract suite pins the expiry transition on the host that can take a clock. CloudflareScopeHost.revokeScopeRoleis the counterpartassignScopeRolewent without, and tenant-level permission fan-out stops writing into archived and reaped scopes — a projection write was recreating storage for scopes the platform believes dead.- Smaller: the work-order engine's
completeoperation declares its total as theMoneyit always returned; each engine'smodel.jsoncarries the manifest'sversion;problemDetailreads a failed control-plane body one way everywhere, which fixed the dashboard showing "409 Conflict" where the refusal's sentence should have been; the control plane can emit the CLI version-advisory headers the CLI has read since it shipped, when a deployment sets the two values.
The scaffold, the example, and the demos
A project from npm create substrat now declares its model the way the reference demos do: entities and operations in their own files, the handlers bound with satisfies, so a handler whose input or return disagrees with its declaration is a compile error at the exact method. Its routes are derived from the operations rather than hand-written, so a path parameter naming no input field is a compile error rather than a 404. One consequence for a new scaffold: the declaration refuses an unbounded list read, so three list reads answer with a page where the old template returned the whole table. The scaffold's dev API moved to port 8891, off the block the repo's own demos use, so it boots beside the demo it was read from. The external-vertical example — the only proof that the published packages compose into a working vertical with no workspace links — had stopped working twice over and runs again on the current registry versions.
If you copied a demo's Vite proxy, check it. The string shorthand proxy: { '/api': 'http://…' } expands to changeOrigin: true, which rewrites the Host header the API derives its OIDC redirect from, so signing in landed on the API's port and a 404. Nine of eleven demo configs carried it. The object form with changeOrigin: false written out is the only spelling that keeps Host, and a gate refuses the other one.
The rally demo is gone. Nothing in the repo imported it and nobody had copied it for months; its concept survives as a historical RFC the booking engine's design still cites. The references a new vertical is built against are now todo — the shape of a vertical built forward from its model — and ticket0 — a deployed one, with an engine composed by call and the deploy workflow. Callout was the reference nobody maintained. And the agent knowledge file now says the API reference page at /api/docs exists and how to reach it.
For hosted operators
- The custom-domain bind refuses every platform zone the deployment configures, not only the default and the app's own — a hostname under a third platform zone used to be accepted and then classified as a platform hostname, which is squatting through the custom-domain flow. And the default routing target a tenant is told to point a custom domain at is now the CNAME production actually publishes; a deployment that never set it was handing out a record that cannot validate. A blank value reads as unset.
- Events can drain to R2. The outbox is never pruned, so every scope's history grew forever inside its Durable Object. The platform sweep now ships undrained events, oldest first, to a sink the platform binds — partitioned NDJSON in a bucket, keyed by event id so a replay overwrites rather than duplicates — and stamps them drained only after the sink confirms. Absent a sink, nothing drains, and nothing is pruned either way; a retention policy is a separate decision that does not exist yet.
- The console's entitlement grant list offers all seven engines. It named five keys and had not moved since it was written — only three of them engines, so four engines could only be granted by hand. A test now fails when the next engine ships without joining it. The versions table reads newest-first with Prev/Next, and the Promote dialog pre-selects the newest admitted version where it used to pick the oldest — a staff default that changed, still behind an explicit confirm. Every console action shows it is running and refuses a second click until the page has re-read.
- A masked scope pull pseudonymises role labels. The protocol engine's party and signatory labels, and the same fact inside event payloads, were exported verbatim under
--masked; a human types a name into that box whatever the docstring promises. - The dashboard's own permission checks can run scope-local, behind a flag, on the test environment first; production is untouched until someone flips it.
Also
- The docs are a book now. Ten chapters under
/book/— the path of one request, the life of one event, the two clocks, the life of one deploy — in a reading order, and the same text as a real EPUB, a single printable page, and one plain-text file for a model to ingest whole. The EPUB was invalid on its first day (an XML id may not begin with a digit) and would not open in Apple Books; it validates now and carries the chapters' diagrams, rendered from the same components as the site. - The rules stop promising a dual-emit window. Consumer dispatch selects on event type alone, so emitting two payload versions side by side delivers both to the same consumer — for an invoicing export, a double invoice. A payload change is a replace, the docs and the engine event pages say so, and the invoicing engine's replace is now the rule rather than the exception.
- Docs that caught up with the code:
readHistoryreturnsoperationandversion; a promote repairs its own installs, soonProvisionmust be idempotent; the router's region-contradiction refusal is specified and not built, stated where an EU-residency reader would look; the issuer's custom OIDC door is named; the Fortnox page leads with the dashboard's Connect flow; a private vertical's push is admitted automatically while a listed one waits; the shop and its neighbours stopped describing demos on Better Auth; the layout tables count tock, the model viewer and the two newer connectors. - The builder's model picker offers only models that can run a tool call, and disables selection outright until the provider credential is set, so a model cannot be picked and then fail mid-run.
Released
| Package | Span |
|---|---|
@substrat-run/contracts | 0.102.0 → 0.111.0 |
@substrat-run/kernel | 0.102.0 → 0.111.0 |
@substrat-run/adapter-sqlite | 0.102.0 → 0.111.0 |
@substrat-run/adapter-cloudflare | 0.102.0 → 0.111.0 |
@substrat-run/vertical-host | 0.102.0 → 0.111.0 |
@substrat-run/vertical-auth | 0.12.5 → 0.14.0 |
@substrat-run/control-plane-api | 0.102.0 → 0.111.0 |
@substrat-run/contract-tests | 0.102.0 → 0.111.0 |
@substrat-run/oidc-rp | 0.7.0 → 0.9.0 |
@substrat-run/model-emit | 0.8.19 → 0.9.3 |
@substrat-run/model-view | 0.2.3 → 0.2.12 |
@substrat-run/model-providers | 0.4.5 → 0.5.1 |
@substrat-run/boundary-lint | 0.4.2 |
@substrat-run/dev-issuer | 0.1.17 → 0.1.26 |
@substrat-run/engine-workorder | 0.11.1 → 0.11.10 |
@substrat-run/engine-booking | 0.7.9 → 0.7.18 |
@substrat-run/engine-invoicing | 0.9.18 → 0.10.1 |
@substrat-run/engine-protocol | 0.12.7 → 0.12.16 |
@substrat-run/engine-invites | 0.7.1 → 0.7.10 |
@substrat-run/engine-absence | 0.5.14 → 0.5.23 |
@substrat-run/engine-metering | 0.5.8 → 0.5.17 |
@substrat-run/connector-scrive | 0.14.7 → 0.14.16 |
@substrat-run/connector-fortnox | 0.4.4 → 0.4.13 |
@substrat-run/connector-planima | 0.2.1 → 0.2.8 |
@substrat-run/cli | 0.30.1 → 0.32.1 |
create-substrat | 0.9.2 → 0.9.6 |
Two packages are new on npm this week: @substrat-run/connector-planima, the Planima connector above, and @substrat-run/model-view, the renderer behind substrat model view and the dashboard's schema view, which last week's entry could only describe. The invoicing engine's minor is the optional export currency; oidc-rp's two minors are the UserInfo fallback and the logout hint; vertical-auth's are the federated logout and the shared invite routes; model-emit's is the journal set; the CLI's two minors carry declared schedules, freshness, events and output shapes on the push.