# Substrat Kernel — Design Document Status: draft v0.1 · Last updated: 2026-07-14 > **Relationship to the master plan.** [docs/master-plan.md](../master-plan.md) is canonical > for strategy, architecture *decisions*, and the decision log — this document never > re-decides anything decided there. This document is canonical for the technical *shape* > of those decisions: contracts, data models, interfaces, lifecycles. Where a shape here > turns out to force a strategy change, the master plan gets a decision-log entry first, > then this document follows. Section references like (§5.2) point into the master plan; > references like (D-4) point to its decision log. Anonymization follows the master plan: **PropCo**, **HouseCo**, **POSCo**, **MediaCo**, **the auth platform**, **the FSM vendor**. --- ## 1. Purpose, scope, non-goals **Purpose.** Specify the kernel precisely enough that (a) implementation can start on case 1 (förvaltar-OS, §8.1), (b) the techy friend's RFC (§13.3) can be exported from it, and (c) the agent-loop acceptance test (§5.6) has concrete contracts to run against. **Scope — the one-step-ahead rule (§6) applied.** This document designs, in order of retrofit brutality: 1. Tenancy model + directory + provisioning (§5.1, D-3) 2. Permission model (D-16) 3. Event envelope + spine (§5.3, D-5) 4. Module manifest + attachment contracts (D-1) 5. Scope-host contract and the two storage shapes (§5.2, D-4, D-14) 6. The adapter seam for everything the kernel consumes (§5.7, D-18) 7. i18n conventions (§6) **Non-goals for v0.1.** Engines (work orders, protocols — designed in their own docs when case 1 forces extraction); the integrations hub beyond its contract stub; billing rails; the AI gateway; spec-registry tooling (§5.6 defers it); anything only a second vertical would consume. ## 2. System topology ```mermaid flowchart TB V["Vertical
user worker we deploy · TypeScript · AI-built"] API["Kernel API surface
getScope(tenantId, scopeId) → stub"] TR["Tenant-root DO
directory · membership · entitlements"] S["Scope DO — one per scope
ACL + data"] QG["Query gateway
Tier 2 only · injects tenant predicates"] SPINE["Event spine → Pipelines → Iceberg on R2"] V -->|"@substrat-run/sdk only — lint bans raw fetch/DB"| API API --> TR API --> S API --> QG S -->|events| SPINE QG -->|reads| SPINE ``` Invariants this picture encodes (§4): - The **only** data path from vertical code to operational data is the scope stub. The stub is capability-scoped: holding it *is* the authorization to talk to that scope, and the scope DO still re-validates every call against its own ACL (§5.4). - The **only** analytical path is the query gateway; it injects tenant/scope predicates before the query reaches the engine. - Events are emitted by kernel/engine code below the API surface. Vertical code cannot emit, suppress, or edit audit events. - Ambient tenancy (§7.8): vertical code never passes tenant/scope IDs after obtaining a stub — context rides inside the stub. ## 3. Core data model: tenant, scope, directory ### 3.1 Entities ```ts interface Tenant { id: TenantId; // branded string, ULID slug: string; // stable, URL-safe, unique name: string; status: 'active' | 'suspended' | 'deleting'; createdAt: Instant; } interface Scope { id: ScopeId; // branded, ULID; globally unique (not per-tenant) tenantId: TenantId; parentScopeId: ScopeId | null; // v1: always null (two levels); field exists so // deeper trees are additive, not a migration slug: string; // unique within tenant kind: string; // vertical-defined vocabulary: 'brf', 'filial', 'brand'… name: string; status: 'provisioning' | 'active' | 'suspended' | 'archiving' | 'archived'; storageShape: 'A' | 'B'; // §5.2; fixed at provisioning, migration is explicit jurisdiction: 'eu' | null; // CF DO jurisdiction; fixed at creation — a DO can // never relocate. 'eu' default for our markets (§7.3) schemaVersion: string; // last applied migration journal entry (§6 below) createdAt: Instant; } ``` Decisions embedded here, open to challenge: - **Two levels in v1, tree-ready schema.** Every known case is two-level (§5.1). The `parentScopeId` column ships day one because *the column* is cheap and *the retrofit* is not; the permission model (§4 below) is written against a path, not a level count. - **ScopeId globally unique**, so a scope stub, an event, or an opaque ref never needs the tenant for disambiguation — but every kernel API still requires the pair `(tenantId, scopeId)` and cross-checks it, so a confused-deputy bug in vertical code fails closed instead of resolving to another tenant's scope. - **`kind` is vertical vocabulary, not kernel enum.** The kernel never branches on it. ### 3.2 Directory DOs are not enumerable (§5.2), so the directory is load-bearing: it is the **only** complete inventory of tenants and scopes, and the input to reconciliation, migration sweeps, billing (active-scope meter, §9), and ops. - One **tenant-root DO** per tenant: scope registry for that tenant, membership, role assignments, entitlements. Lightweight by rule — it holds control-plane state only. - One **global directory** (D1 in the Cloudflare adapter; a SQLite file in the pure adapter): tenant registry + denormalized scope index `(tenantId, scopeId, status, shape, schemaVersion, lastWakeAt)`. - Write ordering: the tenant-root DO is the source of truth for its scopes; the global index is an async projection off its events, and reconciliation treats the tenant-root as authoritative. A scope missing from the global index is a *sweep bug*; a scope missing from its tenant-root is *corruption* and pages someone. The directory is the spine of the **control plane** — the shared platform layer that the N per-vertical deployments of §5.5 all sit on. The tenant registry, entitlement store, hostname map, and admin audit log live here, and the admin's *effecting* surface (provision, suspend, entitlement flips, admin-query RPC) is out-of-band host code rather than module code, because K-8 leaves a vertical no addressable path to another vertical's scopes (K-20). [control-plane.md](control-plane.md) is that design. Note that as of today the *tenant* half of "complete inventory of tenants and scopes" above is **specified but not implemented**: no adapter has a `tenants` table, and a tenant is a foreign-key string on scope rows. ### 3.3 Provisioning lifecycle `provisioning → active → suspended ⇄ active → archiving → archived` - Provisioning is idempotent and journaled in the tenant-root DO: create registry row → initialize scope DO (schema to current version, seed ACL) → mark active. A crash between steps re-runs safely. - Jurisdiction is a provisioning-time decision: the scope DO is minted inside a jurisdiction sub-namespace (`env.SCOPE.jurisdiction('eu').newUniqueId()`) and the guarantee covers where the object runs **and** persists. It can never change after creation, which is why it lives on the `Scope` row, not in runtime config. The DO ID itself is logged outside the jurisdiction for billing/debug — fine (opaque ULIDs), but it belongs in the trust-page fine print. - `archived` keeps the registry row and Tier 2 history forever (bokföringslagen, §5.3); the scope DO's storage is exported to R2 then released. Un-archive is a restore, not a flag flip — this keeps the "active scope" billing meter honest (§9). ## 4. Permission model The model is kernel-owned; the evaluation engine is an adapter (D-16). The model must be decided now (§6: "near-impossible to retrofit"), so this section is the highest-stakes part of this document. ### 4.1 Authored surface: roles @ nodes + capability grants What humans and agents write. Verticals never see the evaluation machinery below. ```ts // The tree of assignable nodes: tenant root, or any scope. type Node = { tenantId: TenantId; scopeId: ScopeId | null }; // null = tenant level // A role is a named bundle of permissions, module-declared or vertical-declared. interface RoleDefinition { key: string; // 'staff', 'field-tech', 'board-member', 'resident' permissions: PermissionKey[];// e.g. 'workorder:create', 'document:read' source: ModuleId | 'vertical'; } // Assignment: a principal holds a role at a node; inheritance flows down the tree. interface RoleAssignment { principalId: PrincipalId; // user or service principal roleKey: string; node: Node; } // Capability grant: a narrow, direct, time-boxable permission at a node, // bypassing roles. Also the cross-tenant mechanism (§5.4). The optional entity // narrows the grant to one entity and its declared descendants — this is how // portal customers see only THEIR facilities/orders inside a shared scope // (the FSM shape: end customers within a filial; see the feature survey §8). interface CapabilityGrant { principalId: PrincipalId; permission: PermissionKey; node: Node; entity?: EntityRef; // entity-narrowed grant (portal customers) expiresAt?: Instant; grantedBy: PrincipalId; // audited, always } ``` **The check API — the seam the adapter sits behind (D-16):** ```ts interface PermissionChecker { check(principal: PrincipalId, permission: PermissionKey, node: Node): Promise; // An allow ALWAYS carries its proof: the tuple chain that granted access // (§4.2) — powering explain, "view as user" (§7.8), and the reviewable diff. explain(principal: PrincipalId, node: Node): Promise; } ``` Rules unchanged by the evaluation choice: - **Deny by default; no negative grants** (K-2). If a real case demands exceptions, model them as narrower roles; revisit only with a decision-log entry. - **Inheritance is downward only** (tenant → scope → future sub-scopes). - **Permission keys are module-namespaced** and declared in the module manifest (§7) — the manifest keeps "who can do what" enumerable and diffable. ### 4.2 Evaluation: a constrained relationship-tuple engine (K-12, plan D-23) The built-in evaluator is a deliberately small FGA/Zanzibar-shaped engine. Internal representation: `(subject, relation, object)` triplets — `principal:… member org:…`, `org:… customer_of facility:…`, `workorder:10024 parent facility:X`, `principal:… role:staff scope:…` — stored scope-locally, with the tenant-level slice cached from the tenant-root DO and invalidated by assignment events. **The derivation algebra is fixed — four rules, not configurable:** 1. Role bundles expand to their permission keys. 2. Permissions inherit down the tenancy tree (`parent` edges between nodes). 3. Permissions flow along **manifest-declared entity parent edges** (§7.1 `entityRelations`), depth-capped (4). This is what resolves "may Anna read workorder:10024?" → workorder→facility→customer-org→member. 4. Membership (principal → org/group). No negation, no intersections, no vertical-authored rewrites. Every future "can we just add…" against this list is a decision-log event, not a patch — the algebra's smallness is what keeps `explain` and the §4.5 diff tractable. **Why this is small here when Zanzibar is not:** Zanzibar's hard problems (snapshot consistency, the new-enemy problem, zookies) exist because Google evaluates against a globally distributed tuple store. Our tuples live in the scope's serialization domain (§5.1): check-after-write consistency is free, and a revoked tuple is invisible to the very next operation. What remains is a bounded recursive query over scope-local rows — a few hundred lines behind `PermissionChecker`. **Proof paths.** An allow returns the tuple chain that produced it. `explain` is the same walk enumerated; "view as user" renders screens against it; the human checkpoint diff renders tuple-set changes as "who gains what, where." **OpenFGA stays the swap target** (D-16): the tuple model is conceptually OpenFGA's, so the adapter swap is a data migration, not a remodel. ### 4.3 Identity sync: authhero organizations Identity is a swappable adapter (D-16); authhero is the reference implementation. The adapter **authenticates** and reports **membership**; the kernel owns the **enforcement facts**. That division is invariant. What varies per audience is only where the identity pool lives and how membership maps in — and there are three audiences, not one: | Audience | Identity pool | Kernel expression (§4.1) | |---|---|---| | **Staff** (ours + tenant operators) | one **central** tenant; one **organization per Substrat tenant** | `RoleAssignment` at nodes | | **Portal customers** (B2B companies inside a tenant's scope) | organization in the tenant's pool | membership tuple + entity-narrowed `CapabilityGrant` | | **White-label consumers** (B2C, per shop/publisher) | **one auth tenant per Substrat tenant** | entity-narrowed `CapabilityGrant`, no role | Login uses the existing refresh-token → org-scoped access-token exchange. Division of labor: - **Identity layer** (adapter): authentication, org membership management, session narrowing — the org claim selects which grants are active when the kernel entrypoint mints the stub (K-8). - **Kernel** (canonical, D-16): the enforcement facts — membership tuples and entity-narrowed grants. Provisioning a portal customer creates both the kernel tuples and the authhero org/membership; **the org is a projection of the kernel directory, never the reverse**. Token claims are never trusted at data access — the owning scope re-evaluates tuples on every call (§5.4). **Staff orgs carry roles, never permissions.** The org membership resolves to a coarse role key (`admin`, `editor`) which the kernel expands via `RoleDefinition` (§4.1). Putting permission *lists* in the identity layer forks the permission vocabulary into two systems that drift, and guts §4.5's diff — the kernel must stay the only place "who can do what" is enumerable. The identity layer says *which role*; the module manifest owns what that role can do. **Sync direction follows the projection rule.** Org membership/role changes land as kernel `RoleAssignment`s (a directory sync). The alternative — deriving grants from token claims per request, storing none — is rejected as the primary path: it leaves the grant tables empty, so §4.5's "who gains/loses what, where" has nothing to render, and it makes every check ride on token freshness. Synced grants stay self-contained, auditable, and enforce through an identity-layer outage. **Per-tenant pools make the identity key tenant-scoped.** With one auth tenant per white-label consumer pool, an external subject id is unique only *within* its pool. The directory's identity mapping is therefore keyed `(tenantId, provider, externalId) → PrincipalId`. A globally-keyed mapping is a cross-tenant identity bleed — exactly what separate pools exist to prevent, and the one invariant to get right on day one. **Why consumers get a pool and staff get an org.** White-label forbids a shared, cross-branded consumer pool: the consumer must never learn the platform exists, and a consumer of two shops is correctly two accounts. Staff are the opposite — someone administering five tenants wants **one** login, which is precisely what an org-per-tenant membership on a central pool buys. Pushing staff down into the per-tenant pools would trade that away for isolation they don't need. **The `orgId ↔ tenantId` join is an explicit, stable directory row** — one per tenant, never reconstructed from names or slugs. Both the staff seam and the sync hang off it. **The IdP is not fronted (K-25).** Templates default to Better Auth, registered as its own pool; the hosted product talks to authhero over OIDC **directly**, as a second pool, rather than putting Better Auth in front of it. The directory stores `(tenantId, provider, externalId)`, so whichever system mints that `externalId` is the identity of record forever — fronting would pin the session library and leave only the IdP swappable, which is backwards. Going direct is also what makes the org claim available to select the tenant, instead of a header the client chooses. **Topology is the enforceable fact, not audience (K-23).** The three rows above name *audiences*, and the audience is descriptive shorthand. What the kernel can actually enforce is a pool's **topology**: `central` (one pool serving many tenants — the same `externalId` in two tenants is the same human) or `tenant-bound` (one pool serving exactly one tenant — the same `externalId` in two tenants is a collision between two humans). The two do not map one-to-one, which the RallyPoint demo makes concrete: a padel player is a *consumer*, but on a **branded** platform where playing at several clubs is the product. Clubs are tenants there, so that player wants the central topology the table above gives only to staff. Read the audience column as "which topology this audience usually wants", and the registry as the fact that decides. Topology is what makes cross-tenant identity answerable at all. *"Which tenants is this login in?"* is an enumeration, distinct from resolution, and it is **only safe on a central pool** — on tenant-bound pools the same `externalId` across tenants names different people, so enumerating would hand one person another's tenant list. A pool that has not declared its topology cannot be reasoned about, so an unregistered provider is refused rather than defaulted. Reference topology: this is what our own auth platform runs for Sesamy today (~100 white-label publisher pools + a central staff tenant with an organization per publisher) — the D-16 dogfooding, cashed in. `demos/callout` implements the staff row only, single-tenant, with Better Auth as a second adapter behind the same seam (M3) — proof the seam is neutral, not a second identity model. ### 4.4 What v1 must express (acceptance list) From PropCo (§8.1): staff with tenant-wide roles; fältpersonal with a role at many-but- not-all scopes; a styrelse member with a role at exactly one BRF scope; a boende with a portal role at one scope; a subcontractor principal holding a time-boxed capability grant into one scope from *another tenant* (the §8.4 wedge, designed-for now, shipped later). From the FSM shape (feature survey §8): a **portal customer seeing only their own facilities and orders inside a shared filial scope** — an entity-narrowed grant resolved through declared entity parent edges (§4.2 rule 3). If the model can't express one of these cleanly, the model is wrong — fix it here before schema. ### 4.5 Human checkpoint surface Permission *definitions* (role definitions, manifest permission keys) change only via reviewed migration — this is one of the two non-negotiable human checkpoints (§4 of the plan). The kernel renders any such change as a single human-readable diff: which principals gain/lose what, where in the tree (§7.8). Assignments (giving a user a role) are runtime data, audited but not human-gated. ## 5. Scope-host contract and storage ### 5.1 The contract ```ts interface ScopeHost { // Kernel authenticates the caller and mints the stub with trusted principal // context (K-8); a mismatched (tenantId, scopeId) pair fails closed (K-3). getScope(principal: PrincipalId, tenantId: TenantId, scopeId: ScopeId): Promise; // Modules register operations at composition time; handlers run INSIDE the scope. defineOperation(name: string, handler: OperationHandler): void; } // The stub is the enforcement primitive: capability-scoped (minted per principal // per scope), revalidated by the owning scope on every call (§5.4), and the ONLY // way code outside the scope reaches it. Callers invoke module-registered // operations by name — closures can't cross RPC, and this is what keeps // "one hop, then local queries" true in production (K-10). interface ScopeStub { invoke(operation: string, input?: I): Promise; } // What an operation handler sees inside the scope — ambient tenancy, no IDs: interface OperationContext { sql: ScopedSql; // module-owned tables in this scope only attachments: AttachmentApi; // documents, comments, custom fields, timeline emit(event: DomainEventInput): void; // spine-bound; envelope stamped kernel-side check(permission: PermissionKey): Promise; // ambient principal + node } ``` The adapter boundary sits exactly here (§5.7): the Cloudflare adapter backs the stub with a Durable Object; the pure adapter backs it with an in-process actor holding one SQLite file per scope, preserving serialized-execution semantics. **Contract tests run against both, forever** (D-14). Two semantics are pinned as *contract*, not adapter behavior (K-6; resolves former open question 7): - **Strict serialization per scope.** Operations on one scope execute one at a time, to completion. The DO over-delivers (input/output gates allow subtler interleavings); the pure adapter implements a per-scope task queue. Kernel and module code may never depend on any interleaving subtler than strict — the conservative semantics are the portable ones. - **A serialization boundary on the stub.** Arguments and results pass through structured clone even when the adapter is in-process, so code can never share mutable state with a scope — the one way a local adapter would silently diverge from RPC. The contract tests enforce this by construction. ### 5.2 Shapes A and B Both shapes present the identical `ScopeStub`; the difference is invisible above the contract (§5.2 of the plan): | | Shape A | Shape B | |---|---|---| | Primary store | DO-embedded SQLite | per-tenant D1 | | DO's job | *is* the database | control plane: ACL, entitlements, locks, counters — mediates every access | | Right for | document-spaces product (consumer #2, D-17) | förvaltar-OS (pending §11 benchmark) | | Ops path | PITR per scope | D1 read replicas, wrangler tooling, export | Open (§11): ~~Shape B for case 1 is presumed~~ — **as of 2026-07-19 the plan proposes Shape A for case 1** and the DO-SQLite limits benchmark still decides. The sizing half is settled (10 GB/DO against a one-BRF scope); what the benchmark now prices is the missing ops surface — **DO SQLite has no export, no import and no HTTP query API**, so per-scope export becomes a kernel contract exercised by the contract tests, and question 13's A→B hatch has no vendor-side support in either direction. If Shape A holds, Shape B does not ship at v1, which retires questions 7 and 10. This document treats shape choice as per-vertical configuration, which is why it lives on the `Scope` row. ### 5.3 Migrations and version skew - Modules own their tables and Drizzle migrations (D-6). The kernel owns the *journal protocol*: each scope records applied versions per module. - Migrations run **lazily on wake**: scope DO init compares its journal to the deployed version and applies forward migrations before serving. - A **reconciliation sweep** walks the directory and wakes stragglers before a deadline. - Therefore: **schema version skew across live scopes is a normal state** (§5.2). Kernel and engine code must tolerate a declared skew window; the manifest declares each migration's `compatibleFrom`, and CI fails a module whose code reads a column its own skew window says may not exist yet. - **Expand–contract is mandatory, CI-enforced.** A release may add (tables, nullable columns, backfills); destructive steps (drop, rename, tighten) must land in a *later* release than the code that stopped using the old shape. The sweep's completion closes the skew window; only then may the next release contract. - **Failure is per-scope and fails closed.** A throwing migration fails that scope's init — one scope down, not the fleet. The sweep retries with backoff and pages past a threshold; recovery is per-scope PITR + a patched forward migration. Sweep progress ("release 42: 487/500 migrated, 13 pending, 0 failed") is a first-class ops-console view. - **Large backfills are jobs, not migrations.** The migration adds the shape; a chunked job (alarm-driven) backfills and marks completion in the journal separately. Backfill writes are flagged so they don't pollute the event spine as fake user activity. - **Review looks at generated SQL, not the schema diff alone** — SQLite's limited `ALTER TABLE` turns some renames into create-copy-rename table rebuilds, which matters at 10 GB. - Shape B difference: the control plane may drive the same journal *externally* via the D1 HTTP API instead of waiting for wake; the wake-time version check still guards serving. - Human checkpoint: migrations are the second non-negotiable review gate (§4 of the plan). `substrat migrate --dry-run` renders the diff per shape; agents can propose, never apply. ### 5.4 Operating the scope fleet - **Inspection.** Cloudflare's Data Studio (dashboard) views and writes SQLite-backed DO storage per instance, and Local Explorer gives the same SQL studio in `wrangler dev`. Both are per-instance: finding "the DO for BRF X" always goes through the directory. There is **no external programmatic path** to DO storage (unlike D1's HTTP API) — programmatic ops go through an audited admin-query RPC on the scope DO, surfaced only in the platform ops console (§6 of the plan). Locally, the pure adapter is plain `.sqlite` files. - **Fleet questions never fan out.** Cross-scope queries go to Tier 2; the directory index answers fleet metadata (versions, status, last wake). - **Rollback is per-scope PITR**, making a bad deploy or migration a controlled failure of the scopes it actually touched. - **Shape B's ops trade**, stated once: D1 tooling (HTTP API, Time Travel, export, read replicas) in exchange for a network hop per query instead of per request, softer residency guarantees (open question 7), and coarser sharding (per-tenant 10 GB rather than per-scope). ### 5.5 Deployment topology and routing - **One kernel-runtime deployment per vertical** hosts that vertical's scope-DO class — the scope schema is kernel + engines + the vertical's modules, and module code (migrations, invariants) executes inside the scope DO, so all scopes of a vertical run identical code and different verticals are separate deployments with separate DO namespaces. Blast radius and versioning are per-vertical. Orchestrating N such deployments is control-plane work (open question 9). - **Vertical app code deploys as WFP user workers** with exactly one privileged binding: a service binding to the kernel entrypoint, which authenticates the request, derives the principal, and mints capability stubs (`getScope`). **Never a raw DO namespace binding** (K-8): WFP supports attaching one, but a raw binding would let vertical code address arbitrary scope IDs and assert principals; the DO's internal ACL check stays as defense in depth, not the first line. - **One router for the whole environment, not one per vertical or per region (K-26).** The hostname map gains a `surface` (a scope can front two apps — a storefront and a back office) and a `region`. Residency is *configuration*, not topology: Regional Services is set per hostname, and the DO jurisdiction is already fixed at provisioning, so the two halves — processing and data at rest — are covered without deploying anything per jurisdiction. - **Custom domains** ride Cloudflare for SaaS custom hostnames: a kernel-owned router worker resolves hostname → `(tenant, scope, vertical)` in the directory (the hostname→scope map is directory data; hostname provisioning — custom-hostnames API, DNS validation, cert lifecycle — is part of scope provisioning) and dispatches to the vertical's user worker. Kernel-owned surfaces short-circuit at the router straight to the scope DO: webhook ingress, auth callbacks, and WebSocket upgrades — realtime terminates on the scope DO, which is where the plan's "realtime nearly free" claim cashes out. ### 5.6 Read paths (K-19) Strict serialization (K-6) queues reads behind writes as well as writes behind writes. The fix is **duration, not concurrency**: a scope's execution domain is a single isolate, so admitting reads concurrently would interleave them on one thread, not parallelize them — and SQLite reads inside the domain are synchronous local calls with no await points to interleave at. Reads get fast by getting *short*. A queue of 50 reads at 30µs each is invisible; at 2ms each it is not. Three read paths, in the order to reach for them. **1. In-scope reads — the default.** A read is a local indexed query against the scope database: one hop, serializable, tens of microseconds. Where a screen needs a shape the normalized tables don't serve cheaply (a dispatcher board joining jobs, customers, techs and totals), a consumer maintains a **denormalized projection table in the same scope database**, updated in the same transaction as the write it derives from. There is no staleness, because there is no second store. §7.3's ownership rules apply unchanged: a vertical projecting engine data keeps its **own side table keyed by the engine's id** (plan decision 28) — the projection is a new use of the side-table pattern, not a new mechanism. Nearly all interactive reads belong here, and this path should be exhausted before either of the next two is considered. **2. Outbox-fed external read model — the escape hatch.** The outbox (K-4) has one sink today (Pipelines → Iceberg). It may have N. A second sink maintains a denormalized read model in external storage (D1/KV) for a scope whose read volume genuinely outgrows its executor. Because emission is transactional with the write, the read model cannot miss a state change — the outbox is what makes an external projection trustworthy rather than a dual-write. Its cost, stated once so it is never rediscovered under pressure: **read-your-writes does not survive the crossing.** D1's Sessions API provides sequential consistency *within D1's version space* — a bookmark names a D1 version and a replica waits to catch up to it before answering — but in Shape A the authoritative write lands in the scope DO and reaches D1 only after the pump runs. At operation-return time there is no D1 bookmark meaning "after my write"; the two version spaces are unrelated. Recovering the guarantee requires either the pump mapping event id → D1 bookmark with the read path waiting on that watermark, or pinning a session's reads to the scope for a window after it writes. Adopting this path without choosing one is how a system acquires phantom-stale reads. **3. Tier 2 (Iceberg / R2 SQL).** Reporting, reconciliation, audit, cross-scope history. Columnar and seconds-scale by construction (plan §5.3) — **never a UI list view**. It is a history tier, not a read tier, and treating it as the latter is a category error. **Global read replication is not a platform read tier.** Rejected as a default on three grounds. (a) It contradicts K-7: replicating a `jurisdiction: 'eu'` scope's data to other regions breaks the residency guarantee, and D1 offers location *hints*, not jurisdiction guarantees (open question 7). (b) Read-your-writes does not cross the outbox, per above — the Sessions API cannot repair a boundary it cannot see. (c) A scope maps to one business, whose users are geographically clustered; **placing the execution domain well at provisioning beats replicating it**. Global replication is sanctioned *per surface*, for **public, read-heavy, staleness-tolerant** surfaces — customer portals, tracking links, availability views — where the projection is a derived subset rather than the operational record, and where traffic can exceed internal usage by an order of magnitude. **`readonly` on operations** *(additive; not yet implemented)*. Operations should declare `readonly: true` in the manifest. It changes nothing observable today. It is the hook every later option needs: routing a read to a read model, skipping outbox and migration machinery, admitting reads during a migration freeze, or relaxing the lock if that ever proves worthwhile. Declaring it now is free; retrofitting the distinction across a hundred shipped operations is not. ## 6. Event spine ### 6.1 Envelope (the schema-versioned contract, D-5) ```ts interface DomainEvent { id: EventId; // ULID; idempotency key downstream type: string; // 'workorder.completed' — module-namespaced schemaVersion: number; // per event type; AsyncAPI is the source (§5.6) occurredAt: Instant; tenantId: TenantId; // stamped by kernel, not caller scopeId: ScopeId; // " actor: PrincipalId | SystemActor; entity: EntityRef; // opaque (entity_type, entity_id) — §3 of the plan piiClass: 'none' | 'pseudonymous' | 'direct'; // drives crypto-shredding (§5.3) subjectId?: DataSubjectId; // present iff piiClass ≠ 'none'; keys the shred payload: T; // validated against generated JSON Schema on emit } ``` Non-negotiables: - Tenant/scope/actor are **stamped by the kernel** from the stub's ambient context — a vertical cannot mislabel an event's origin. - `piiClass` and `subjectId` are required at the type level in the AsyncAPI spec: an event type that *could* carry PII cannot be declared without classification. This is the kernel convention that makes lake-side crypto-shredding (§5.3) total rather than best-effort. - Emission is transactional with the write it describes (outbox in scope storage, drained to Pipelines) — an event without its write, or a write without its event, is a bug class the contract tests target explicitly. ### 6.2 Consumption Engines subscribe to event types (declared in their manifest), never to each other's internals — the star topology (D-19). Delivery is at-least-once with the event `id` as idempotency key; consumers are required-idempotent, and the contract test suite includes a duplicate-delivery harness. Ordering is guaranteed only within (scope, module) — never across modules or globally (§7.3, K-11). ## 7. Module system: manifest + attachment contracts ### 7.1 Manifest The manifest is what makes an engine self-describing to agents and buyers (§5.6). One file, spec-first, checked into the module package: ```ts interface ModuleManifest { id: ModuleId; // '@substrat-run/engine-workorder' version: string; // semver; kernel enforces (§6 of the plan) kernelContract: string; // semver range of kernel API it targets permissions: PermissionDeclaration[]; // keys + human descriptions (fuel for §4.5 diffs) events: { emits: EventTypeRef[]; consumes: EventTypeRef[] }; // AsyncAPI refs migrations: MigrationJournalRef; // Drizzle journal + skew windows (§5.3 above) attachmentTargets: EntityTypeDeclaration[]; // entity types it exposes to attachment entityRelations?: EntityRelation[]; // declared parent edges, e.g. workorder→facility; // permission flows along them (§4.2 rule 3) entitlementKey: string; // the SKU flag that gates loading (D-20) api?: OpenApiRef; // emitted OAS for the HTTP surface, if any (D-22) searchables?: SearchableDeclaration[]; // FTS/vector registration (§6 of the plan) ui?: UiContributions; // routes, nav, entity views, widgets (§7.4) } ``` Loading rule: the kernel loads a module only if the tenant's entitlements include `entitlementKey` and the semver ranges are satisfiable. Entitlements are kernel-owned precisely because they gate this loader (D-20). ### 7.2 Attachment contracts The kernel owns no entities (D-1); it offers services that bind to opaque refs: ```ts type EntityRef = { entityType: string; entityId: string }; // opaque to the kernel interface AttachmentApi { documents(ref: EntityRef): DocumentCollection; comments(ref: EntityRef): CommentThread; timeline(ref: EntityRef): ActivityTimeline; // projection of spine events for ref customFields(ref: EntityRef): TypedCustomFields; // registry-declared, typed, indexed (D-6, §7.5) } ``` Every attachment item (document, comment, custom-field value) carries a mandatory **visibility classification** (`internal | customer`) from day one (K-13) — the FSM survey shows internal/external flags pervading every audience-shared surface, and like `piiClass`, a classification is only total if it was never optional. The kernel enforces referential *permission* (attachment access checks the permission of the owning entity's declared permission key) but not referential *integrity* across the opaque boundary — a module deleting an entity is responsible for emitting the deletion event; attachment GC is a spine consumer. ### 7.3 Module storage model Engines own **tables and migrations, never databases**. The decisive reason is transactional: the outbox pattern (K-4) requires a domain write and its event to commit in one transaction, and the scope is the consistency domain (D-4) — everything that must commit together lives in one database. One scope database, shared by every module active in that scope: kernel tables (`_substrat_*`), engine tables, vertical tables. Ownership rules: 1. **Namespaced tables.** Each module prefixes its tables (`workorder_*`); the `_substrat_` prefix is reserved. Within a module, FKs and joins are fine and encouraged. 2. **Cross-module FKs are forbidden.** Cross-module references use the opaque `EntityRef` stored as plain columns — the star topology (D-19) applied to schema. Engines version and license independently, which physical schema entanglement would break. CI-lintable: parse migration SQL, reject `REFERENCES` across prefix boundaries. 3. **Migrations are module-owned and module-journaled** (`_substrat_migrations` tracks `(module_id, version)`); on wake, each active module's pending migrations apply independently — kernel, then engines, then vertical. An engine upgrade never touches another module's tables. 4. **Per-tenant flexibility never mutates engine schema.** Tenants get typed custom fields (registry-declared, D-6; queryability obligations in §7.5), not per-tenant `ALTER TABLE`. Engine schema is identical in every scope at a given version — what keeps skew-window reasoning tractable. Honest limit: SQLite has no intra-database permissions, so within a scope, table ownership is convention + CI lint + the migration review checkpoint — not runtime enforcement. Correct trade: the boundary the product sells is the tenant/scope boundary (physical); modules within a scope are co-trusted because the vertical composed them and a human reviewed the composition. **The 10 GB question.** 10 GB caps *operational truth only* (blobs → R2, history → Tier 2, archived scopes export out) — roughly 5–10M live work orders per scope; not a v1 driver. Pressure valves, in order, before intra-scope sharding: (1) **retention into Tier 2** — closed items keep events in Iceberg, drop operational rows; (2) **split along the tree** — a 10 GB scope is often two consistency domains wearing one name (`parentScopeId` is ready); (3) **per-engine databases** — available precisely because rule 2 forbids cross-module FKs; each shard carries its own outbox. Never shard by user: B2B data is queried scope-wide and users cross-cut scopes — the consistency domain is the business unit, never the person. Shape note: in Shape A, per-engine sharding would mean per-engine DOs and lose cross-engine serialization; in Shape B one control-plane DO fronts N databases and keeps a single serialization point while shedding the storage cap. **"Scope outgrew 10 GB" → that scope migrates to Shape B (or splits), not "shard the DO"** — retroactive justification for `storageShape` being a per-scope field. Semantic commitments pinned now — free today, breaking changes once verticals exist (K-11): 1. **Event ordering is guaranteed only within (scope, module)** — never across modules, never globally; consumers are idempotent and cross-module order-tolerant. 2. **No cross-module transaction is ever promised** (star topology already implies it). 3. **The outbox is a per-database mechanism** — one per consistency domain, not "one per scope." ### 7.4 UI composition model (K-15) **Microfrontend outcomes, monolithic build mechanics.** Each vertical is one React app, composed **at build time** from manifest-declared UI contributions. Runtime federation (module federation / single-spa) is rejected: it solves independently-deploying teams — a problem we don't have (one user worker per vertical, engines versioned via npm) — and costs duplicated framework instances, theming fragmentation, cross-bundle routing/state, and a larger hallucination surface. One React tree, one router, one design-system instance; version conflicts caught by the compiler via semver + `kernelContract` ranges. **Manifest `ui` contributions** — the extension-point model (VS Code-style): ```ts ui?: { routes: [{ path, screen, permission }]; nav: [{ label /* i18n key */, icon, to, permission }]; entityViews: [{ entityType, view }]; // the EntityRef → view registry widgets: [{ slot, component, permission }]; settingsPanels: [{ label, component, permission }]; } ``` The kernel-owned shell reads contributions at the vertical's build, wires routing, and renders nav/routes **permission-aware from the proof-path checker** — which is also what makes "view as user" (§7.8 of the plan) a shell feature rather than per-screen discipline. **The entity-view registry is the star topology in the UI layer.** A screen showing a provenance ref `EntityRef{workorder, 10024}` never imports workorder UI — it asks the registry "who renders `workorder`?" Cross-engine UI composition through opaque refs, zero imports between engine UI packages: the same rule as the data layer (D-19), falling out of the attachment contracts. **Engine UI packages are headless-first.** Hooks/data layer (`useWorkOrders()`, state machines, SDK permission checks) separate from the presentation components. Three override levels for a vertical: (1) theme tokens; (2) **copy-and-own** — engine screens are generated *into* the vertical shadcn-style, so agents edit concrete files rather than fighting an abstraction (the §7.8 generation-time-governance lesson); (3) replace entirely, keeping the headless layer. **The shell and what sits above it:** - `@substrat-run/shell` — seeded from shadcn-admin as a one-time copy (proven on the auth platform), then stripped and owned: auth pages replaced by identity-adapter flows, nav replaced by the contribution renderer. The shell stays domain-free — kernel chrome only (org/scope switcher, permission-aware nav, settings, members, audit viewer, notifications, connector UI). - **Pattern kit** — the actual differentiator, one level above the shell: the recurring vertical-software screen patterns the FSM survey documents (survey §7): master-data panel + tabbed detail + related-entity tables, filtered lists with saved views and counts, status flows, entity cards, visibility-aware rendering. Built from shadcn primitives; **the vocabulary agents compose screens from** — patterns, not divs. - **Three surfaces**, one headless layer: office shell (above), **field mobile** (one-handed capture flows: accept job, checklist + photo, time, signature — a separate lightweight shell), **customer portal** (consumer-grade, per-tenant brandable, tiny surface). Specialized components (dispatch board, planning calendar, map) are buy/adopt decisions deferred to the engines that need them (one-step-ahead). **Escape hatch, not foundation:** a future `slot` contribution type accepting a web component gives isolated islands in other frameworks (one legacy widget, not a polyglot architecture). Additive — designed for, not built. ### 7.5 Engine extension model (K-17, K-18; plan decision 26) The plan promises verticals "vocabulary, extra states, form fields" (§3) while engines own their state machines and schema as invariants. Two mechanisms make both true at once. Both are pinned now because they are contract surface: cheap to specify before the first engine ships, breaking changes after — and without a kernel-level convention, every engine improvises its own extension style, which is the per-engine snowflake problem the manifest exists to prevent. "If a vertical needs to fork an engine, the engine drew its line wrong" (§3) is a discipline statement; this section is its mechanism. **Substates (K-17).** Engine states are a coarse invariant lattice; verticals refine them, never bypass them: ```ts // vertical manifest, additive to §7.1 substates?: [{ entityType: string; // 'workorder' state: string; // the engine state being refined: 'in_progress' values: string[]; // ['awaiting_parts', 'pending_customer_approval'] — i18n keys }] ``` - The engine's state-machine declaration marks which states admit substates (`extensibleStates`); invariant-bearing states (a signed protocol) mark none. - The kernel stores the current substate alongside the engine state and validates writes against the manifest declaration; substate changes emit spine events like any mutation, so timelines and Tier 2 see them. - Transitions **within** an engine state are vertical-owned (any declared substate to any other of the same engine state); transitions **between** engine states remain engine operations only — no substate path can skip `completed`. Making a substate *block* an engine transition ("obligatorisk för status") is open question 11's manifest-declared guard, not substate semantics. This is where the FSM incumbents' value concentrates (status-flow nuance: `awaiting_parts`, `pending_customer_approval`), so it is exactly where "vertical not powerful enough" would otherwise materialize first. **Queryable custom fields (K-18).** D-6's field-definition registry is not just write validation — registration is the moment the platform learns what to index. Two obligations attach to it: 1. **Registration materializes a typed index.** Custom-field values are stored typed in kernel-owned storage (`value_text | value_num | value_date | value_bool`, chosen by the registry type), and fields declared `filterable`/`sortable` get real SQLite indexes at registration. Types make comparison and sort semantics correct (the `'9' > '10'` bug class); declarations make indexing possible at all — freeform JSON can index nothing. 2. **Engine list APIs accept registry-declared predicates.** Every engine list/query operation takes custom-field filter predicates and sort keys, with correct pagination and counts; kernel query machinery composes the join between engine tables and the custom-field index inside the scope DB. The vertical never writes SQL — the same posture as the Tier 2 gateway, and consistent with §7.3: the ban is on *modules* entangling with each other's tables, not on the kernel mediating a declared, typed query path. Contract-tested: the module suite includes a filtered/sorted/paginated list over a custom field, on both adapters. Why this cannot wait: the everyday screen of every vertical is a filtered list over vertical-specific fields ("open orders where `district = 'Uppsala'`, sorted by contract priority, page 3"). Without obligation 2, verticals fetch-all-and-filter in application code — pagination and counts break silently — and the workaround screens ship before any retrofit does. Salesforce indexes declared custom fields for precisely this reason; SharePoint list views collapsing past a few thousand items is the counterexample. ## 8. Adapter matrix (D-14, D-18) Every row: pure TypeScript interface; two adapters minimum; contract tests pass on both. | Contract | Cloudflare adapter | Pure adapter | v0.1? | |---|---|---|---| | ScopeHost | Durable Object | in-process actor + SQLite file per scope | ✅ | | Directory index | D1 | SQLite | ✅ | | Event transport | Pipelines → Iceberg/R2 | append to local Iceberg (or JSONL→DuckDB) | ✅ | | Query gateway engine | R2 SQL | DuckDB over same Iceberg | ✅ | | Jobs/cron/workflow state | Queues, Cron, Workflows | SQLite job table + poller | ✅ | | Identity | — (adapter is the auth platform, default) | same, or stub issuer for tests | ✅ | | Permission evaluation | built-in default | same code (it's pure); OpenFGA later | ✅ | | Blob storage | R2 | filesystem | ✅ | | Key management | CF secrets | local encrypted store | ✅ (shredding needs it) | | Notification transport | Resend/SES/SMS | console/file sink | stub | | Telemetry | OTel → Datadog | OTel → stdout | convention only | | Search backends | D1/FTS + Vectorize | SQLite FTS5 + sqlite-vec | defer to first search consumer | Never adapters (D-18): event spine semantics, tenancy/permission model, entitlements, module manifest. ## 9. Repository and package layout (§5.8) pnpm monorepo, published as: ``` @substrat-run/sdk — the narrow typed surface verticals import (the only import) @substrat-run/kernel — contracts + kernel services (runtime-agnostic core) @substrat-run/adapter-cloudflare @substrat-run/adapter-sqlite @substrat-run/contracts — Zod contract schemas (source of truth, D-22) + emitted OAS/JSON Schema artifacts, checked in and CI-diffed @substrat-run/cli — `substrat dev` (pure-SQLite composition), migrate --dry-run, scaffold; the MCP server ships here @substrat-run/contract-tests — the suite every adapter and module must pass engines/* — manifest-carrying packages, versioned independently demos/* — demo verticals (private, never published): demos/callout first, demos/bikeshop later reusing the same engines (reuse proof) ``` Lint/CI guardrails shipped from day one (§5.6): ESLint rules banning raw `fetch`, raw DB drivers, and `Date`-based ID generation in vertical code; contract tests as the module admission gate; both adapters green as a merge requirement. ## 10. Enforcement model — mechanism per guarantee The §4 (plan) claims, restated as testable mechanisms — this table is the spec for the isolation test suite and the 15-minute demo's failure moment: | Generated code cannot… | Mechanism | Test | |---|---|---| | read another tenant's data | data only reachable via `ScopeStub`; DO revalidates caller ACL on every call; `(tenantId, scopeId)` cross-check fails closed | adversarial vertical attempts stub forgery, ID swap, direct DO addressing | | leak tenants in reporting | gateway injects tenant/scope predicates; verticals hold no lake credentials | query with missing/foreign predicate → rejected, audited | | call third-party APIs raw | credentials live in the hub; lint bans `fetch`; egress policy on user workers (WFP) | vertical with hand-rolled fetch fails CI *and* runtime egress | | skip or forge audit | envelope stamped kernel-side; emission transactional via outbox | write-without-event harness; origin-spoof attempt | | mislabel PII | `piiClass` required by event-type schema; emit validates against generated schema | unclassified event type fails spec build | | self-approve schema/permission changes | migrations + permission definitions only apply via reviewed path; agents get dry-run only | `substrat migrate` without approval token is inert | ## 11. Testing strategy 1. **Contract tests** (`@substrat-run/contract-tests`): every kernel contract, run against both adapters in CI, forever (D-14). Includes the duplicate-delivery, skew-window, and crash-during-provisioning harnesses. 2. **Isolation suite**: the §10 table above, run adversarially; results published (the trust page, §7.8 commercial lessons). 3. **Reconciliation invariant**: Tier 2 sums = Tier 1 balances on a seeded synthetic; runs continuously in staging. 4. **The agent loop as acceptance test** (§5.6): Claude Code + manifest + specs + one reference vertical scaffolds a new toy vertical to the two human checkpoints, with only mechanical pushback. This is the recurring benchmark and the demo (§13.4). ## 12. Milestone 1: the 15-minute demo — minimum kernel cut What must exist, and nothing more (§13.4): directory + provisioning for two tenants × two scopes · permission model with `check`/`explain` · scope stub on the **pure-SQLite adapter only** (Cloudflare adapter may lag — the demo is about contracts, not hosting) · event envelope + local spine · module manifest loading a toy engine · `substrat dev` + scaffold + specs enough for the agent loop · the isolation suite's tenant-boundary test, shown **failing the attack live**. Explicitly deferred past milestone 1: Tier 2 gateway (log events locally, query later), documents service, integrations hub, notifications, search, i18n *content* (the string externalization convention is day one; translations are not). ## 13. Open design questions (technical siblings of §11 in the plan) 1. Permission model: does §4.4's acceptance list hold against PropCo's real org chart? Walk it with real names before freezing schema. (Evaluation engine decided: K-12, plan D-23.) 2. Stub minting and transport: exact capability format (signed token vs DO-held session) — needs a security review pass of its own. 3. Outbox drain semantics on Shape A vs B — same contract, different failure modes; enumerate them before the contract tests are written. The drain is explicitly allowed **more than one sink** (§5.6: Pipelines → Iceberg, plus an optional read model): does a failing or lagging sink block the others, is the watermark per-sink, and does a read model sink need its own replay path when a projection schema changes? 4. Skew-window declaration format in the manifest — per-migration or per-release? 5. Tenant-root DO scope: is entitlement checking on its hot path (every module load) or cached in scope DOs with event invalidation? 6. `EntityRef.entityId` format — require ULIDs from modules, or accept opaque strings and forbid ordering semantics? 7. ~~Shape B EU-residency wording.~~ **Answered (K-30).** D1 supports a **jurisdiction** (`eu`, FedRAMP), which is a hard constraint — the database runs and persists only within it, and read replicas are created only inside it. That is a guarantee, not a hint, and it puts D1 on the same footing as DO jurisdictions. Location *hints* are the weaker thing and remain best-effort: "providing a location hint does not guarantee that D1 runs in your preferred location". So Shape B **can** carry an EU claim — via jurisdiction at creation, never via a hint. 8. Latency budgets for the storage-shape benchmark (§5.2): set p50/p95 budgets *before* running it — e.g. a portal page and a work-order mutation, EU user → router → vertical worker → kernel → scope, warm and cold, DO-primary vs D1-primary. 9. Per-vertical kernel deployments (§5.5): how does the control plane orchestrate N deployments' versions, and where does a shared-engine upgrade roll first? 10. Shape B physical layout: one D1 per tenant (scope isolation via adapter-injected `scope_id` predicates — weaker physical story) or one D1 per scope (~50k databases per account allowed — keeps §7.3's model identical across shapes)? Decide in the storage-shape benchmark. 11. Cross-engine transition guards (FSM survey §8: "obligatorisk för status" — checklist completion blocks order status transitions): vertical-owned orchestration (visible glue, but droppable by AI edits — weak for compliance-grade gates) vs a manifest-declared guard the kernel evaluates before the transition (all modules share the scope DB, so it's cheap). Decide when the protocol engine lands; the manifest change is additive. 12. Engine upgrades × extension surface (§7.5): when an engine release changes its state machine or fields, what happens to vertical manifests declaring substates within a changed state, or custom fields feeding its list APIs? Needs a manifest revalidation step in the upgrade pipeline — dangling declarations fail the *upgrade*, not runtime — and an owner for fleet-wide execution across per-vertical deployments (couples with question 9). §5.3 covers scope-level mechanics; this is the layer above: who initiates an engine version rollout across verticals, including engines licensed to strangers. Field evidence brackets the design space: Salesforce 2GP push upgrades (vendor executes centrally) vs Odoo/OCA OpenUpgrade (scripts mailed to the community, upgrades lag by a year) — plan §7.8; [platform-landscape drilldown](../research/platform-landscape-drilldown.md) §7. 13. **The escape hatches are designed but untested.** §5.2 and §7.3 promise that a scope which outgrows its execution domain *migrates to Shape B or splits* — and K-11's whole justification for banning cross-module FKs is that this keeps those moves "ops changes, not contract changes." Neither has ever been executed. A Shape A → Shape B migration is documented as "explicit" (§3.1) but has no tested path, and a scope **split** — the answer to a consistency domain drawn too large — would require physically moving data and rewriting every `EntityRef` to it. Prove both once, on a throwaway scope, before either is needed under load; the split is the one that would hurt most to discover is impossible. Couples with question 8 (run the storage-shape benchmark, budgets first). 14. **Platform-staff auth, and whether destructive admin actions need four eyes** (K-20). The `PlatformActor` seam lets the control plane be built and tested behind a dev stub, but exposing a cross-tenant console anywhere non-local needs the real thing — and staff auth is a *different regime* from tenant-user auth (SSO, MFA, no self-service signup, short sessions, a closed population, plausibly its own IdP tenant). Separate question, same area: tenant `suspend` and scope `archive` are one-click outages for a paying customer, so does the control plane need an approval step (four-eyes) on destructive actions, and if so is that a session concern or a workflow in the console? Unanswerable until the action list is real — which is the argument for building the actions first. Couples with D-16 (identity is a swappable adapter). 15. **Entity parent edges are permanent, and the walk expands all of them — so an entity can never be re-parented.** `OperationContext` exposes `link(child, parent)` and no `unlink`; there is none anywhere in the kernel or either adapter. Links are `INSERT OR IGNORE` into `_substrat_tuples`, which module code is forbidden to write (boundary-lint R4), so there is no workaround at the vertical layer either. The checker's walk expands **every** `relation = 'parent'` tuple at each frontier, so linking a new parent does not replace the old one — it **adds** a second path. Consequence: a vertical that "moves" an entity leaves it reachable from where it used to be, forever, with no remedy at any layer. This is not a Canopy quirk (agent-loop-007 found it asking a filing product's most basic question, "can I move a document to the right matter?"). Every declared edge in the tree has a routine business event that breaks it: `bike → customer` when a bike is **sold**; `order → customer` when a customer is **acquired**; and — the one that matters — **`facility → customer` when a building changes management company**, which is not an edge case in property management and PropCo is the anchor case (§8.1). The old manager's staff would retain access to every facility and work order under it, permanently and silently. No demo caught this because no demo ever moves an entity. The design space is not obvious and the trade is real: tuple deletion is the simple answer but it destroys the audit property K-4 rests on (a tuple that once granted access is evidence of why an access was allowed), so the candidates are a tombstone/`revoked_at` tuple the walk skips (keeps history, costs a predicate on the hot path), a kernel-mediated `relink` operation that emits a spine event (auditable, but must define what happens to proofs already issued), or accepting permanence and requiring verticals to model movable containment as vertical data rather than as a parent edge (cheapest, but it silently removes entity-narrowed grants from every domain whose containment changes — which is most of them). Decide before PropCo accumulates a year of links; retrofitting revocation onto edges already in production means auditing every grant that ever resolved through them. **Narrowed by K-21 (D-32), not closed.** Two of the four options are now off the table: **deletion**, because an operated compliance product must be able to show both that access was revoked *and* the trail proving it was once granted, and **accepting permanence**, because "the old management company's staff keep access forever" is an access-revocation failure an ISO 27001 / SOC 2 auditor reads as a finding rather than a design choice. What remains is tombstone versus kernel-mediated `relink`, and K-21 observes these are not alternatives at the storage layer: the tombstone is the substrate, `relink` is an operation over it (tombstone the old edge, link the new, emit a spine event). So the *storage* answer is settled and the open half is narrower than it was — whether the kernel offers `relink` as an operation, and what happens to proofs already issued against a since-revoked edge. That half does not block membership revocation, which has no re-parenting analogue. 16. **Consumer dispatch routes on event *type* alone, so D-28's dual-emit deprecation window is unimplementable — and actively dangerous.** D-28 says a real change to a shipped payload means a `schemaVersion` bump plus dual-emit through a deprecation window, so consumers can migrate. The kernel offers nothing to make that safe. A manifest's `consumes` entry carries a `schemaVersion`, but registration discards it (`declaredConsumes = new Set(manifest.events.consumes.map((c) => c.type))`), the registry is `Record` with no version dimension, and dispatch selects `WHERE o.type = ?` with no version predicate. Both adapters agree, so this is the contract, not an adapter quirk. Consequence: dual-emitting v1 and v2 delivers **both events to the same consumer**, and the deprecation window — the mechanism meant to make migration safe — becomes the mechanism that double-processes. For `invoicing.underlag-exported`, whose consumer is by design an accounting connector, that is a double invoice, silently, in production. The declared `schemaVersion` is currently decorative: it reads like a routing key and is not one, which is worse than absent. Found while bumping that event to v2 (docs/design/commerce-gaps.md §3.1); the bump shipped as a **replace**, deliberately violating D-28's dual-emit rule, because a replace fails loudly (a v1 consumer's strict parse rejects v2 and dead-letters) where dual-emit fails silently and expensively. That is a workaround, not a resolution: either dispatch honours `(type, schemaVersion)` and D-28 works as written, or D-28's dual-emit clause is struck and every payload change is a replace with a loud-failure contract. The current state — a decision the platform cannot execute — is the one option that should not survive review. Decide before a third party consumes an engine event; after that, the choice is someone else's outage. 17. **Is vertical-owned PII inside the erasure guarantee, or outside it?** D-27's guarantee-surface test says data that must sit inside the permission/audit/**GDPR** surface belongs in an engine rather than a template. Events carry a `piiClass`, which classifies the *event*; nothing classifies a **table**. A vertical storing a customer's shipping address in its own side table (the placement docs/design/commerce-gaps.md §4.2 argues for on reshaping grounds) is holding personal data the kernel has no view of and no erasure story for. Either the kernel offers an erasure/retention primitive that vertical tables can declare into — in which case §4.2's placement needs revisiting — or every vertical re-implements erasure, which is precisely the duplication the guarantee-surface test exists to prevent. Unowned today because no demo stores personal data worth erasing; a real customer address is the trigger. ## 14. Design log | # | Date | Design decision | Notes | |---|---|---|---| | K-1 | 2026-07-12 | `parentScopeId` column ships in v1 even though trees are two-level | Column cheap, retrofit brutal; permission model written against paths | | K-2 | 2026-07-12 | Deny-by-default, no negative grants in v1 | Keeps `explain` and the human-readable permission diff tractable | | K-3 | 2026-07-12 | ScopeIds globally unique; APIs still take and cross-check the pair | Confused-deputy bugs fail closed | | K-4 | 2026-07-12 | Event emission transactional via outbox in scope storage | "Write without event" becomes a tested bug class, not a convention | | K-5 | 2026-07-12 | Milestone-1 demo runs on the pure-SQLite adapter only | Proves contracts-first is real; Cloudflare adapter may lag the demo | | K-6 | 2026-07-12 | Strict per-scope serialization + structured-clone stub boundary are contract semantics, not adapter behavior | Conservative semantics are the portable ones; resolves former open question 7 | | K-7 | 2026-07-12 | `jurisdiction` on the Scope row, chosen at provisioning, immutable; `eu` default | DOs can never relocate; jurisdiction covers run + persist — the EU-sovereignty mechanism (§7.3) | | K-8 | 2026-07-12 | Verticals never hold raw DO namespace bindings; one service binding to the kernel entrypoint, which authenticates and mints stubs | Stub minting with trusted principal context is the enforcement point; DO ACL stays defense in depth | | K-9 | 2026-07-12 | Contracts Zod-first via zod-openapi on Hono; OAS/JSON Schema emitted + CI-diffed; no TypeSpec design phase — the contracts package is the design artifact | Implements master-plan D-22; reference implementation is in-memory/pure-SQLite, never throwaway mocks | | K-10 | 2026-07-12 | Stub exposes `invoke(operation, input)`; `sql`/`emit`/`check` live in the `OperationContext` handlers see inside the scope | Closures can't cross RPC; module code runs colocated with data (§5.5), preserving "one hop, then local queries" on the DO adapter | | K-11 | 2026-07-13 | Module storage model (§7.3): engines own namespaced tables + module-journaled migrations in the shared scope DB, never databases; no cross-module FKs; event ordering only within (scope, module); no cross-module transactions promised; outbox is per-database | Outbox atomicity requires one DB per consistency domain; forbidding cross-module coupling keeps per-engine sharding and Shape B migration available as ops changes, not contract changes | | K-12 | 2026-07-13 | Permission evaluation = constrained relationship-tuple engine (§4.2): fixed four-rule algebra, no negation/config, scope-local tuples, proof-path decisions; grants gain optional entity narrowing; manifests declare entity parent edges | Implements plan D-23; the FSM portal-customer case needs entity-graph resolution; per-scope serialization makes the mini-engine small (no zookies) | | K-13 | 2026-07-13 | Attachment items carry mandatory `visibility: internal \| customer` | FSM survey: internal/external flags pervade every portal-shared surface; classifications are only total if never optional (same reasoning as piiClass) | | K-14 | 2026-07-13 | Shared `money` schema in @substrat-run/contracts: decimal-string amount + ISO 4217 code, branded; engines never invent money representations | Multi-currency evidence (SEK/NOK) + Tier 1/Tier 2 reconciliation needs uniform money handling | | K-15 | 2026-07-13 | UI composition (§7.4): build-time composition of manifest-declared `ui` contributions into one React app per vertical; entity-view registry for cross-engine rendering; headless-first engine UI with copy-and-own screens; `@substrat-run/shell` seeded from shadcn-admin; three surfaces (office/field/portal); runtime microfrontends rejected; web-component slot kept as future escape hatch | Federation solves team-scale deployment we don't have and costs theming/versioning/agent ergonomics; the manifest, proof-path checker, and EntityRef registry are exactly the primitives a pluggable UI needs | | K-16 | 2026-07-13 | In-scope composition (demos/callout/spec/testrun.md §9.2): engines export plain functions taking `ctx`; a vertical's operation may call them — same transaction, same serialization; registered operations are default bindings of these functions. Plus `ctx.link(child, parent)` writing manifest-declared relation tuples, and kernel-managed at-least-once local event dispatch with a `_substrat_deliveries` journal | Vertical-owned orchestration (D-19) needs one-transaction composition (e.g. price-then-complete); invariants hold because everything still flows through ctx; `link` is the write path for D-23 rule 3 | | K-17 | 2026-07-14 | Vertical **substates** (§7.5): verticals refine engine states via a manifest `substates` declaration; engines mark which states admit them (`extensibleStates`), invariant-bearing states admit none. Transitions *within* an engine state are vertical-owned; transitions *between* engine states stay engine operations only. Kernel stores and validates the substate and emits spine events for substate changes | Implements plan decision 26. Delivers §3's "extra states" promise without letting a substate path skip an engine state; FSM status-flow nuance (`awaiting_parts`, `pending_customer_approval`) is exactly where "vertical not powerful enough" would otherwise materialize first. Substates *blocking* an engine transition is open question 11's guard, not substate semantics | | K-18 | 2026-07-14 | **Queryable custom fields** (§7.5): D-6's field registry carries two obligations — registration materializes a *typed* index (`value_text \| value_num \| value_date \| value_bool`; `filterable`/`sortable` fields get real SQLite indexes at registration), and engine list APIs accept registry-declared filter/sort predicates with correct pagination and counts, the kernel composing the join inside the scope DB | Implements plan decision 26. Types make comparison/sort correct (the `'9' > '10'` bug class); declaration is what makes indexing possible at all — freeform JSON indexes nothing (the SharePoint/unindexed-JSONB counterexample). Kernel-mediated typed queries do not violate §7.3: the ban is on *modules* entangling with each other's tables, not on the kernel mediating a declared query path | | K-19 | 2026-07-15 | Read-path tiering (§5.6): in-scope projections first (denormalized tables in the scope DB, committed with the write); outbox-fed external read model as the escape hatch; Tier 2 is history, never a UI read tier. Global read replication rejected as a platform tier, sanctioned per-surface for public stale-tolerant reads. Operations gain an additive `readonly` manifest flag | Serialization is a *duration* problem, not a concurrency one — one isolate cannot parallelize reads, so reads get fast by getting short. The rejection is load-bearing: replicas break K-7 residency, and D1 bookmarks cannot restore read-your-writes across the outbox boundary (D1 versions ≠ DO versions). `readonly` is free now, expensive to retrofit | | K-20 | 2026-07-15 | **Control plane** ([control-plane.md](control-plane.md), implements plan decision 30): the control plane is the **shared platform layer N per-vertical deployments sit on**, not an admin app — §5.5 already shares routing, custom hostnames, tenancy, identity, entitlements and Tier 2; only the scope-DO class is per-vertical. Merging the DO classes is rejected (lockstep engine upgrades across differently-owned verticals — §7.8/open question 12). Builds what §3.2/§3.3/§5.4 specified and nobody implemented: `tenants` table, real lifecycle transitions, an entitlement store that reads `manifest.entitlementKey`, and a `PlatformActor` + append-only admin audit log wrapping every mutation (including `HostAdmin`'s five unaudited methods). The admin's **effecting** half is out-of-band host code — never module code; its **record-keeping** half may later be a vertical in a platform tenant. Only path into scope data stays §5.4's audited admin-query RPC. Billing deferred: meters 1–2 free, 3–4 uncomputable | K-8 (no raw DO namespace binding) plus one-deployment-per-vertical means an admin *vertical* has no addressable path to another vertical's scopes — impotent, not dangerous — so the effecting half must be out-of-band, and granting it the path would be building this anyway. The audit log is the platform's own `_substrat_outbox` on the same argument as K-4: a surface that can act without a durable record of who acted is worse than no surface (and that a hand-built log *is* the outbox is the argument for eventually dogfooding the record-keeping half). Forces open question 5 (entitlement check on the module-load hot path or cached with event invalidation) to be answered by a benchmark, and promotes open question 9 (orchestrating N deployments) from footnote to centre | | K-21 | 2026-07-19 | **Revoking a tuple tombstones it, and assignment authority is a set comparison the kernel owns** (implements plan decision 31; narrows open question 15). Two decisions the membership seam ([membership.md](membership.md) §4/§5.1) cannot be built without, settled together because both are "how does the kernel say *no longer*". (1) **Tombstone, never delete.** A tuple that stops granting access keeps its row and gains a `revoked_at` the checker's walk skips — deletion is rejected outright. This costs a predicate on the hot path and is the price of the audit property K-4 rests on. It applies to every relation in `_substrat_tuples` / `_substrat_tenant_tuples`, membership included, so there is exactly one revocation mechanism rather than one per relation. Open question 15's remaining half (whether the kernel offers `relink` for entity parent edges, and what happens to proofs already issued) sits **on top of** this rather than beside it: `relink` = tombstone the old edge + link the new + emit a spine event. (2) **Assignment authority is one kernel-resolved comparison, not N checks.** §5.1 bounds role assignment by the assigner's own authority — assign `R` at `N` only if you already hold every permission `R` carries at `N` — which asks whether one principal's effective set *contains* a role's set. `ctx.check` answers one permission at a time and each call walks tuples, so N-checks repeats the same walk N times per invite acceptance. The kernel instead resolves the assigner's effective permission set once and compares. Effective means narrowing-aware: an entity-narrowed grant does not satisfy the bound for the unnarrowed permission | (1) is D-32 cashed in. Once the paid layer is an *operated* compliance product pursuing ISO 27001 + SOC 2 Type II, "who had access, when was it revoked, and what proves it was granted" stops being an architectural preference and becomes a deliverable — and deletion cannot produce the second half. That also retires open question 15's "accept permanence" option, since a building changing management company while the old manager's staff keep access is exactly the finding an auditor writes up. Deciding storage now and `relink` later is safe **because** they compose; deciding them as alternatives is what would have forced a rewrite. (2) follows §4.3's rule that the kernel stays the only place "who can do what" is enumerable — the comparison is that enumeration turned inward, and putting it in the kernel is what stops each vertical hand-rolling a privilege-escalation check. The cost is real and worth stating: every invite acceptance pays one set resolution, which is why it is one and not N | | K-22 | 2026-07-19 | **The membership seam is a connector, and `OrgId` becomes a branded ULID** (implements plan decision 31; [membership.md](membership.md) §4). Corrects this document's own earlier sketch of an in-scope `ctx.members.*` write. (1) **Not `ctx`-shaped.** `ctx.link` works *because entity tuples are scope-local* — same DB, same transaction. Membership tuples are tenant-wide and live in the directory (§3.2's tenant-root DO), reached by RPC to a separate DO on Cloudflare, so an in-scope membership write would be a cross-DO write inside a scope transaction: two serialization domains, no coordinator, and an orphaned membership if the scope transaction rolls back after the directory write lands. Moving membership *into* the scope to recover the transaction is rejected — it makes one tenant-wide access fact into N scope-local copies (a revocation hazard) and breaks rule-4 resolution, which happens at the tenant level. (2) **The seam is D-18's triage rule**: the engine owns the invariant and emits a fat event inside its own transaction; a privileged executor outside module code consumes it and effects membership through the host admin surface. Atomic where it counts — a rollback leaves no event and no membership change — and module code still never obtains a cross-tenant stub (K-3, K-8 untouched). (3) **Prompt dispatch, not a timer**: the executor is driven inline after commit with the outbox as durability/retry backstop, the shape local consumer dispatch already has (K-16, `_substrat_deliveries`). The contract stays eventually consistent because that is what makes it correct under crash, but the common case completes inside the request. (4) **Correlation is specified now**: the emitted event carries a correlation id the executor's admin-log row carries back, so the split trail joins by construction. (5) **`OrgId` is a branded ULID** and orgs are a real directory record, slug and name as attributes rather than identity — this also supplies §4.3's required `orgId ↔ tenantId` row | (1) is the correction that matters: the in-scope sketch read as obviously right *because* `ctx.link` exists, and the assumption that all tuples are scope-local is stated in this document (§4.2) and was stated on the docs site until #53. Naming where tuples actually live is what makes the seam designable. (2)/(3) together are the transactional-outbox pattern, which is the correct answer when a distributed transaction is unavailable rather than a concession — the inline-call alternative only *looks* synchronous while hiding a two-phase write. Driving the executor promptly is what keeps "eventually consistent" from becoming "visibly slow": a design that answered invite latency with a UI spinner would be papering over it. (4) and (5) are both retrofit traps: control-plane.md §3 already flagged the split audit trail as the main thing this pattern worsens, and a correlation id is impossible to reconstruct after the fact; `orgId` is the only unbranded identifier in the system, and once tuples encode `org:acme` in production, branding it means rewriting every membership tuple and org grant and auditing every access that resolved through one. The executor itself is not a one-off — D-31 commits to the admin's record-keeping half becoming a vertical, which needs exactly this bridge for `tenant.provision_requested` | | K-23 | 2026-07-19 | **Identity pools are registered, and declare topology rather than audience** (§4.3; completes #48 after K-22's tenant-scoped key). A directory `identity_pools` row per provider declares `central` (serves many tenants — the same `externalId` across tenants is one human) or `tenant-bound` (serves exactly one, named on the row — the same `externalId` across tenants is two humans colliding). `linkIdentity` refuses an unregistered provider and refuses a tenant a tenant-bound pool does not serve; resolution needs no extra check because K-22's key already scopes it. A provider string names exactly one pool, so separate per-tenant deployments take distinct provider strings (`oidc:`), which the identity contract's own comment already assumed. New `listIdentityTenants(provider, externalId)` answers "which tenants is this login in" and is **central-only** — it throws on a tenant-bound pool rather than returning the single obvious answer, because asking is a category error the caller should see | K-22 stopped the bleed but left the modes *unenforceable*: `provider` was a free string with nothing behind it, so any provider could claim any tenant and nothing recorded whether cross-tenant sameness meant "same person" or "id collision". Without that fact, cross-tenant identity cannot be offered safely at all — which is the actual blocker for a branded multi-club product, not the key. Splitting topology from audience is what RallyPoint forces: §4.3's table gives central pools to *staff*, but a padel player is a consumer on a branded platform where cross-club play is the product, so the audience labels are shorthand and the topology is the enforceable fact. Refusing unregistered providers rather than defaulting follows the kernel's deny-by-default posture — a defaulted topology would be a guess about whether two humans are one | | K-24 | 2026-07-19 | **Staff reads are audited into a separate access log that drains rather than expires** (control-plane.md §4.6; closes #43, depends on K-20's per-person actors being real — #42). Every `HostAdmin` read records actor, method, the tenant/scope it asked about, a bounded parameter summary and the **result count**, into `_substrat_access_log` — in the **directory** (the ControlPlaneDO's own SQLite, beside the admin log), not D1 and not the per-scope outbox. **Not** `_substrat_admin_log`: a mutation is permanent evidence and that log is append-only forever, a read is operational history — one table would force one retention policy, the stricter would win, and read noise would be kept forever while burying the mutation rows an auditor came for. **All reads, not a curated subset**: a subset decides in advance which reads will not matter, and "who enumerated every tenant" is what an incident asks. `result_count` is what separates navigation from an incident. **Hot storage and retention are different things**: the log ships with a `drained_at` marker and only drained rows are pruned, so the DO window is a *storage* bound while the record's lifetime belongs to Tier 2. Until that sink exists the window IS the retention, and that is a stated **limitation**, not a policy. The **§7 bound is preserved**: no admin-query RPC into scope databases, so this covers directory metadata and never tenant business data — adding logging must not be the moment that limit widens | The write side was already complete — all 16 `AdminAction` members record in both adapters — while a staff member could enumerate every tenant, scope, role and audit row with no trace. Defensible at one operator; not once we are a processor for EU employee data and a customer asks who looked at their directory, which is what D-32's ISO 27001 / SOC 2 commitment makes answerable-or-else. The separate-log split is the kernel's own prediction cashed in: `scope-host.ts` already recorded reads as unaudited *deliberately* because "a durable record of who READ the directory is a different feature with a different retention story" — this is that feature, in the shape that comment implied. The drain marker is the correction that matters: pruning on age would destroy evidence while *calling itself* a retention policy, which is the failure K-21 rejected for tuples reappearing one layer up. D-30 already observed the converse — "a hand-built admin audit log **is** `_substrat_outbox`" — so the outbox's own `drained_at` is the precedent, and shipping the column before the sink is what keeps the retrofit off an append-only log that by then holds history. Volume is **not** the pressure: this is staff reads, a handful of operators, thousands of rows a day rather than millions, which a singleton DO absorbs comfortably. The pressure is duration — an append-only log with no drain grows forever in a store that cannot be sharded, which argues for the marker rather than against the DO | | K-25 | 2026-07-19 | **The IdP is not fronted: whatever mints `externalId` becomes the identity of record** (§4.3; settles how authhero arrives alongside Better Auth). Templates default to **Better Auth**, registered as its own pool. The hosted product's IdP is **authhero over OIDC, talked to directly** — *not* behind Better Auth — and registers as a second pool (`oidc:`). Both coexist: K-23's registry makes providers distinguishable and separately enforceable, the adapter chain already takes a list, and the choice is therefore **per deployment rather than per codebase**. The rule that decides it: the directory stores `(tenantId, provider, externalId)`, so whichever system mints that `externalId` is the identity of record **forever** — fronting authhero with Better Auth would put Better Auth's user ids in `_substrat_identities` and pin the layer that was supposed to stay swappable, making the IdP swappable and the session library not. Going direct also buys the thing a front cannot: §4.3's **org claim selects the tenant**, replacing a client-supplied venue/tenant header with something the token asserts. Consequence to state rather than discover: one human arriving through two pools is **two identity rows and two principals** — correct, since different pools are different subject namespaces, but it means "local-dev me" and "production me" are not the same person | D-16 committed to identity being a swappable adapter with our auth platform as the reference implementation; this says where the seam actually cuts, which the demos were about to answer by accident. The retrofit argument is the same one D-33's `OrgId` and K-22's tenant-scoped key already paid for: an identifier the directory keys on is brutal to change once rows exist, so the question is settled before there are any. The org-claim payoff is what makes "direct" more than aesthetic — RallyPoint is the concrete case, where clubs are tenants and the venue is currently a header the client chooses; under an org-scoped token the tenant is asserted rather than requested, which is the difference between a routing hint and an authorization fact. Templates keep Better Auth because a template is copied and must run with no external dependency: pointing every starter at a hosted IdP would make "clone and go" require an account, which is exactly the friction a template exists to remove | | K-26 | 2026-07-19 | **One environment-wide router; verticals are unreachable except through it; residency is configuration, not topology** (§5.5; #31 step 4). A single kernel-owned router worker resolves `hostname → (tenant, scope, vertical, surface, region)` and dispatches. **Not per vertical** — cert and DNS lifecycle in one place means a new vertical gets custom domains for free — and **not per jurisdiction**, because Cloudflare exposes that as configuration: **Regional Services** pins TLS termination and processing to a region **per hostname** ("Regional Hostnames"), and the **DO jurisdiction** (`eu` / `us` / `fedramp`) already pins storage and execution, fixed at id creation and unchangeable (K-7). Two halves, both covered, nothing deployed per region. **`surface` is new and load-bearing**: §5.5 specified `hostname → (tenant, scope, vertical)`, one hostname per scope, but a scope can already front two apps — the shop's storefront and back office, RallyPoint's player app and manager console — so the map needs to say *which app answers*, and retrofitting that once hostnames are issued and DNS records exist is the same trap `OrgId` and the identity key already taught. **Trust boundary**: vertical workers get no public route, only a service binding from the router — otherwise the router's assertion of `(tenant, scope)` is a header anyone reaching the worker directly can forge. **Hostname provisioning is scope lifecycle, not a string**: the custom-hostnames API, DNS validation and cert issuance are states a scope passes through, per §4.2. **Cache invalidation is deferred to open question 5's answer**, not given a second one | This preserves D-30 rather than eroding it, which is worth stating because a shared component in front of every vertical *looks* like the bundling shortcut D-30 rejects: that shortcut is one DO class per customer's code, which forces lockstep engine upgrades across differently-owned verticals. A router forwards; it does not merge, and deployments stay separate. The residency finding is the one that changed the shape: a per-jurisdiction router deployment is intuitive and wrong — hostname is already the key the router indexes on, so region is one more column rather than a second topology. Two honest costs. Regional Services is an **Enterprise add-on**, so the EU-residency claim carries a plan dependency that belongs in D-32's cost model rather than being discovered in procurement. And routing puts the directory on the **request hot path**, which makes suspension latency real — §7 calls suspension "a live weapon", and a cached route that keeps serving a suspended tenant blunts it; that is the same tension as open question 5 (entitlement checks: hot path or cached with event invalidation) and must not be answered twice. Cloudflare logs a `DurableObjectId` outside its jurisdiction for billing and debugging, which the ID design already anticipated — `contracts/src/ids.ts` requires ids to encode nothing precisely because of it | | K-27 | 2026-07-19 | **The router presents a shared secret and the vertical verifies it; the vertical reads its node through one kernel helper** (§4.7). K-26 makes the trust boundary "vertical workers have no public route, only a service binding from the router". That is correct and it is a **deployment fact**, not an enforced one — `workers.dev` is ON by default, so the property is one forgotten toggle away from false, and nothing in the code would notice. The consequence is not partial: the `(tenant, scope)` the vertical serves is a header, so anyone who can reach the worker directly reads any tenant's data. So the router also sets `x-substrat-router` from a shared secret, and `readRoutedNode` (kernel) verifies it in constant time. The router **strips every inbound `x-substrat-*` header by prefix** before setting its own, so a header added later is covered without anyone remembering. `readRoutedNode` distinguishes three outcomes and refuses to collapse them: **null** (no assertion — a standalone deploy substitutes its own node), **throw** (present but unsigned, incomplete, or malformed), **a node**. Standalone is gated on its own `STANDALONE` flag rather than folded into `ALLOW_DEV_HEADER`, because that flag lets any caller be any principal and wanting a single-tenant box should not require switching on impersonation | Defence in depth is usually a smell — two mechanisms for one property means neither owner is clear. It is warranted here because the two are not redundant: the config rule sets who *can reach* the worker, the secret sets who *may assert a tenant*, and only the second survives a misconfiguration of the first. The cost is real and stated: every vertical deployment now needs a secret provisioned in step with the router's, and a rotation is a two-sided change. Putting the reader in the **kernel** rather than in each vertical is the same instinct as the permission check — five demos each re-deriving how to trust a header is five chances to get it wrong, and the one that does is not obviously broken | | K-28 | 2026-07-19 | **Workers for Platforms fits the architecture unchanged: a user worker in a dispatch namespace MAY define its own SQLite-backed Durable Object class** — verified empirically, not inferred. This was the pivotal unknown, because `defineScopeDO(MODULES)` puts kernel, engines and module code INSIDE the DO: a vertical does not talk to a Durable Object, it **is** one. No Cloudflare doc states whether a dispatch-namespace script may define a DO class; the evidence only leaned yes (the dispatch script API returns `migration_tag`; `migrations` is an accepted multipart-upload metadata field). So it was tested: a user worker exporting a SQLite `ScopeDO`, deployed with `wrangler deploy --dispatch-namespace`, driven through a dynamic-dispatch worker doing a real `CREATE TABLE`/`INSERT`/`SELECT`, with one DO per scope by name. **Accepted, and it runs**; distinct scopes get distinct DO ids. So a customer's vertical becomes a user worker exporting its own `ScopeDO`, uploaded by US with OUR credentials — the customer never holds a Cloudflare token — and **D-30 holds by construction** (separate scripts, separate DO classes, no lockstep engine upgrades across differently-owned verticals). The router's `verticalFor` swaps a static service binding for `env.DISPATCH.get(name)`: the same `Fetcher` type, one function. **Second finding, operational: a newly-deployed user worker is NOT immediately dispatchable for every scope.** A scope whose DO placed in a colo the script had not reached yet failed with `Worker not found.` for ~15s while sibling scopes on the same script succeeded. It resolved on its own and stayed stable. So deployment is not instantaneously global, and an orchestration layer must not treat upload-succeeded as ready-to-serve — bind hostnames or move a channel pointer only after a readiness check, and expect a cold-start window per new version | Verified rather than assumed because the cost of being wrong was a redesign: had it been rejected, either verticals stop shipping code inside the DO (contradicting how every engine and vertical is written) or they stay ordinary Workers and WfP's script-cap lift, per-customer tags and per-script limits are all forgone. **WfP is a paid add-on and is NOT enabled on our own accounts** (`code: 10121` on both) — the same class of plan dependency K-26 flagged for Regional Services, and it belongs in D-32's cost model rather than being met in procurement. It does not gate the next milestone: platform-owned deploys already work through the ordinary Workers upload API, which is the whole of "we do the deploys, not the developers", and that path demonstrably supports DO classes because it is what `wrangler deploy` uses for `substrat-fsm` today. The propagation finding is the kind that only shows up in a real deployment — it would not have been found by reading docs, and it would have been found in production instead. Spike removed after recording, per its own README; it is in git history at `368b340` | | K-29 | 2026-07-20 | **The router retries a transient dispatch failure once, for bodyless requests only; readiness is tolerated, not proven** (K-28's second finding). There is no propagation-complete signal to wait for — Cloudflare exposes no such API — so a fixed delay is a guess that verifies nothing, and a single health probe is worse than useless here: at the same instant on the same script one scope returned 200 while another returned `Worker not found.`, so one probe would have reported ready. What IS checkable is per-scope, since the failing unit is (script, colo) and the colo follows the DO — but that only covers scopes that already exist, because a scope created after promotion places its DO fresh. **So the mitigation is a bounded retry rather than a gate.** Cloudflare's documented advice for this error is to return 404, which is right for a script that is genuinely absent and wrong during the window — it converts a self-healing gap into a hard failure for whichever tenants land in a cold colo. **Bodyless requests only**: a retry is safe only when the first attempt provably had no effect, and if the failure came after the request reached the vertical, replaying a POST runs the mutation twice. A page load is what a person sees fail; a double-charged customer is worse than a 502. Bounded at one retry, so a deleted vertical or a stale channel pointer fails fast instead of hanging | Chosen partly because it survives being WRONG: the colo-propagation explanation is an inference from the symptom, not something Cloudflare documents, and a retry does not depend on that diagnosis holding, whereas a delay tuned to a guessed mechanism does. Note what this is NOT — it does not make promotion safe, it makes the window survivable. Probing each bound scope before moving a channel pointer is still worth doing once the orchestration layer exists, and a `deploymentStatus` on `verticalVersion` becomes a nice-to-have rather than the only thing standing between a promotion and a broken tenant. **Armed, not active**: the router dispatches through a static service binding today, and `Worker not found.` is a dispatch-namespace error, so this cannot fire until the Workers-for-Platforms swap lands. Written and tested now because the knowledge was expensive and perishable — it came from a live deployment rather than a document, and would otherwise be rediscovered from a customer | | K-30 | 2026-07-20 | **Residency is expressed in the HOSTNAME, enforced by a wildcard regional config per jurisdiction, and verticals are deployed per jurisdiction while the router stays single** (§4.7; refines K-26). TLS terminates **before any of our code runs**, so the directory cannot influence where it happens — the hostname is the only lever. Cloudflare's Regional Hostnames API accepts wildcards: "wildcards are supported for one level". So `*.eu.substrat.run` and `*.us.substrat.run` are **two configs covering every tenant**, rather than one Regional Hostnames call per tenant. That is not merely less work: a per-hostname call happens AFTER the hostname resolves, so there is a window in which an EU tenant's TLS terminates outside the EU. The wildcard config exists before any tenant does, which deletes the window instead of sequencing around it. Default hostnames therefore become `..substrat.run` — two levels, so **Universal SSL does not cover them and Advanced Certificate Manager is required**. **Verticals deploy per jurisdiction** (`substrat-fsm-eu` with an EU D1 and EU DO jurisdiction), because bindings are per-deployment: a worker that CANNOT reach US storage beats one that chooses not to, the same argument as the router having no `SCOPE` binding. The **router stays single** — K-26 stands, because it is stateless and holds nothing regional. `verticalFor` keys on `(slug, region)`. Customer custom domains are not under our zone, so they keep a per-hostname regional config as one more field in the Cloudflare-for-SaaS onboarding that already issues their certificate. The router **rejects a request whose hostname region contradicts its scope's jurisdiction** | This corrects a mistake worth recording: the first design put the region in the map and kept hostnames flat, which answered a ROUTING question when the question was about TERMINATION. Nothing the router knows can move TLS, because TLS already happened. **ACM is the price and it is worth it** — roughly ten dollars a month removes a per-tenant API call, an entire class of provisioning failure, and a compliance window; that is cheap for what it deletes, and it belongs in D-32's cost model beside the Regional Services and WfP line items. The router's mismatch check is **not** the "second enforcement point that can disagree" rejected for suspension: that would re-decide something `getScope` owns, whereas this detects a contradiction between two systems (edge configuration and the directory) which can genuinely drift. Two constraints fall out and both foreclose an otherwise-obvious choice. **Workers KV is incompatible with Regional Services**, which rules it out as the cache for the router's per-request directory read — the tempting fix for that hot path would have silently voided the residency claim for every hostname it served, and it binds open question 5. And **`demos/callout` binds one `AUTH_DB` for the whole deployment**, so Better Auth identities for tenants in different jurisdictions currently share one database regardless of either scope's jurisdiction; per-jurisdiction vertical deployments fix that structurally, whereas one deployment with two bindings would fix it only as often as the code picks correctly | | K-31 | 2026-07-20 | **Provisioning is control-plane-driven (pull). The vertical's self-registration is a dev affordance and must be unreachable in production** (first-flow §6 decision 3, answered; #31 blocker 1). The deciding fact is not preference but capability: **only the vertical can create a usable scope DO**, because the DO class bundles kernel, engines and modules and lives in the vertical's own deployment (D-30/K-20) — the control plane's `SCOPE` binding is the module-less placeholder. A control-plane → vertical call is therefore unavoidable whichever way registration nominally flows, and once it exists, push is a second way to do the same thing. **Self-serve requires pull regardless**: a customer clicking "create an instance" is a decision the vertical has no way to learn, and push can only mirror what a vertical already did. **The trust direction inverts correctly**: push hands the vertical a token that creates tenants and grants entitlements in the directory — substantial authority pointing the wrong way once verticals are customer code (K-28) — whereas pull means the platform decides and the vertical executes for a tenant the platform already recorded, matching K-27's boundary. **Two phases, already modelled**: `scopeStatus` begins with `provisioning`, so a directory row exists before the vertical has created the DO and only the vertical's confirmation moves it to `active` — the same recorded-is-not-serving distinction `hostnameStatus` makes, so this needs no new machinery. Push survives **gated**, like `STANDALONE` and `ALLOW_DEV_HEADER`, so a vertical run standalone still registers itself for local dev | Worth a decision rather than an implementation detail because the skeleton's own note said push could "invert later without changing the directory contract", and that is half true: the contract survives, the AUTHORITY model does not, and inverting it after customer code is running is a migration rather than a refactor. Two costs, stated. The control plane needs a binding per vertical — the same static map the router carries, the same Workers-for-Platforms swap later, and the same auth shape as K-27 (no public route plus a shared secret). And partial failure becomes real: a directory row can exist with no scope behind it, which is precisely what `provisioning` is for, but it makes the **reconciliation sweep (#49) load-bearing rather than hygiene** — "0 failed" is unfalsifiable today, and under two-phase provisioning it would also be false. Instance-creation order is fixed by K-30: tenant → scope (pull) → hostname bound in the scope's own jurisdiction → activate, because a hostname whose region contradicts its scope must never resolve |