import type { AdminAction, ListPage, Connection, ConnectionFilter, ConnectionId, ConnectionGrant, ConnectionGrantRecord, ConnectionSecret, CreateConnectionInput, OpenConnection, ProjectedConnectionGrant, ProjectedConnectionKey, AccessLogEntry, DelegatedReadRecord, BindHostnameInput, AdminLogEntry, OpsFailureEntry, SweepRunEntry, SweepRunKind, SweepRunOutcome, ModelUsageEntry, ModelUsageSummary, CapabilityGrant, CreateTenantInput, Decision, Instant, DomainEvent, DomainEventInput, PlatformRequestInput, PlatformRequestId, PlatformRequest, PlatformRequestFilter, PlatformRequestStatus, PlatformRequestFailure, EntitlementGrant, EntitlementGrantInput, EntitlementView, MeterReading, EntityRef, IdentityLink, IdentityPool, Jurisdiction, ModuleId, ModuleManifest, ScheduleSpec, SystemGrant, CreateOrgInput, Node, Org, OrgId, OrgMembership, PermissionKey, PlatformActorId, ChannelName, ChannelHistoryEntry, DnsRecord, HostnameBinding, HostnameStatus, PromotionAcknowledgement, PublishVersionInput, RegisterVerticalInput, VerticalServingState, RouteTarget, DirectoryDump, IdentityMembership, PrincipalId, ResolvedIdentity, RoleAssignment, RoleDefinition, QueryScopeInput, ReadScopeTableInput, Scope, ScopeDump, SubjectShredReceipt, ScopeQueryResult, ScopeId, ScopeStatus, ScopeTable, DenialFilter, DenialSummary, PermissionDenial, Coverage, BeginImpersonationInput, ImpersonationFilter, ImpersonationSession, ImpersonationSessionId, ScopeTablePage, StorageShape, Tenant, TenantId, TenantRole, TenantStoreHandle, AttachmentRecord, BlobStoreHandle, Visibility, Vertical, VerticalChannel, VerticalVersion, TenantStatus, Page, CountedPage, FreshnessSpec, DrainedEvent, EntityHistoryInput, EventFacetInput, EventCauseInput, EventEffectsInput, EffectsTree, InvocationEventsInput, InvocationEvents, DeadLettersInput, DeadLetter, CauseChain, EventFacetResult, HistoryEntry, ErrorCode, PlatformRequestFailureOrigin, IssueEntry, IssueStatus, IssueStatusInput } from '@substrat-run/contracts'; import type { ModelUsageFilter, ModelUsageInput, ModelUsageWindow } from './model-usage.js'; import type { SealedSecret } from './secret-box.js'; import type { SearchHit, SearchOptions } from './search-index.js'; import type { EntityVersion } from './entity-version.js'; import type { JobDriveReport, JobHandler, JobRun, JobRunFilter, StartJobRunInput } from './job-run.js'; /** * What a caller asks a paged read for (#811). * * `filters` is a plain record because the DECLARATION is what constrains it: an * undeclared key is refused by `listQuery` rather than typed away here, so the * refusal names the column and lists the ones that exist — which a structural * type could not do for a hand-written manifest. */ export interface PageParams { /** Defaulted to `LIST_PAGE_DEFAULT` and capped at `LIST_PAGE_MAX` by `ctx.page`. */ readonly limit?: number; /** One of the declared `sortable` columns. Defaults to the first. */ readonly sort?: string; readonly order?: 'asc' | 'desc'; readonly cursor?: string; /** * Narrowing, per declared column. A scalar is an equality; an ARRAY is the set * of permitted values (`IN`), and an empty array permits none of them. */ readonly filters?: Readonly>; /** Also count the filtered set — the declaration's `total`, passed through. */ readonly total?: boolean; } /** * The scope-host contract — the adapter seam (§5.1 of the design doc). * * Module code registers OPERATIONS; callers invoke them through a capability * stub. The operation handler runs INSIDE the scope's execution domain * (Durable Object on the Cloudflare adapter, per-scope actor locally), which is * what makes "one hop, then local queries" true in production and what makes * invariants enforceable: the handler sees sql/emit/check, the caller sees * only invoke(). * * Contract semantics, pinned (K-6): * - Strict serialization per scope: one operation at a time, to completion. * - Structured-clone boundary: inputs and results are cloned even in-process; * code can never share mutable state with a scope. */ export type SqlValue = string | number | bigint | Uint8Array | null; export interface ScopedSql { query>(sql: string, params?: readonly SqlValue[]): T[]; exec(sql: string, params?: readonly SqlValue[]): { changes: number; }; } /** What an operation handler sees — ambient tenancy, no IDs passed around (§7.8 of the plan). */ export interface OperationContext { readonly tenantId: TenantId; readonly scopeId: ScopeId; readonly principal: PrincipalId; readonly sql: ScopedSql; /** * The operation's instant (#812) — the ONLY clock module code may read. * * Module code has no other one: `new Date()` and `Date.now()` are R6 * violations, the same way `node:*` is an R2 one. Reaching for the wall clock * directly is how 95 call sites came to stamp rows the kernel could not see, * and how anything genuinely time-dependent — an absence window, a metering * period, a booking hold — became untestable except against real time. * * **Stable for the whole invocation.** Every call within one operation returns * the same instant, so two rows written in one transaction cannot disagree * about when they were written, and an event agrees with the row it describes. * That is a promise about the value, not an optimisation: it is what lets a * frozen clock make a scenario deterministic rather than merely slower. * * Read at the top of the operation, not lazily — the host stamps it when the * context is built, so the value is the instant the operation BEGAN, not the * instant a particular line ran. * * ```ts * const now = ctx.now(); * ctx.sql.exec('INSERT INTO shop_carts (id, owner, created_at) VALUES (?, ?, ?)', [id, owner, now]); * ``` * * Injectable host-side — **on the pure host** (#956). `SqliteScopeHostOptions.clock` * is the same seam as `fetch`, and it is what a frozen-clock test and a deterministic * replay both need. `CloudflareScopeHostOptions` declares `clock?: never` instead: the * instant is read once per invocation inside the ScopeDO, which workerd constructs and * the host only reaches through a stub, so an options bag cannot get there. What that * costs is written up beside that `never` and in * `apps/docs/reference/contract-tests.md` — both hosts run the same expiry predicate, * but only on the pure one can a test reach the transition without waiting for it. */ now(): Instant; /** Envelope is stamped kernel-side (id, occurredAt, tenant, scope, actor); input is validated. */ emit(event: DomainEventInput): void; /** * Enqueue a PLATFORM INTENT (docs/architecture/platform-intents.md) — how a sandbox-clean vertical asks * the platform to perform a privileged action (e.g. provision a sibling scope) without an upward * call. Writes a durable row into this scope's `_substrat_platform_requests` spine, atomic with * the operation; the platform pulls and executes it later, knowing the tenant inherently (it reads * this scope's DO). Call it AFTER the vertical's own permission check — authorization is the * vertical's, isolation is the platform's. Origin fields (id, requestedAt, requestedBy) are * stamped kernel-side; returns the new request id so the caller can report/track it. Throws if the * scope already holds `MAX_PENDING_PLATFORM_REQUESTS` pending intents (backpressure). */ requestPlatform(request: PlatformRequestInput): PlatformRequestId; /** * Read back the intents THIS scope enqueued (#618) — the outcome half of `requestPlatform`. * * The write has always been a first-class kernel verb and the read was nothing: a vertical * could ask the platform to do something and then had no supported way to learn whether it * happened. Rule 3 permits a projection read of `_substrat_*`, but a hand-rolled `SELECT` * against the spine is a private schema a vertical should not be pinned to — this is the * stable shape, returning the same `PlatformRequest` the platform settles. * * The point is what an app can then TELL A USER: a contract whose signature request settled * `failed` can say so on its own screen, instead of showing a document that appears to be out * for signature and is not. Synchronous and scope-local (it is this scope's own table); * newest first, `limit` defaulting to `DEFAULT_PLATFORM_REQUEST_HISTORY_LIMIT`. * * Read-only by construction: the kernel owns every write to this table (rule 3 forbids module * code writing `_substrat_*`), so an intent's status is only ever the platform's answer. */ platformRequests(filter?: PlatformRequestFilter): PlatformRequest[]; /** * This entity's version (#901) — the ULID of the last event about it, or * `null` if nothing has ever been emitted about it. * * There is no version column anywhere, and there is deliberately not going to * be one: `_substrat_outbox` has recorded `entity_type` and `entity_id` * against a monotonic ULID since it was written, so every mutation that * followed the fat-event rule already versioned the thing it touched. See * `entity-version.ts` for why the alternative — a `_version` column bumped by * an emitted trigger — was rejected despite working. * * ```ts * const before = ctx.versionOf({ entityType: 'customer', entityId: id }); * // …mutate, emit… * ctx.versionOf({ entityType: 'customer', entityId: id }) !== before // true * ``` * * **Conservative, by construction.** ANY event about the entity moves this, * including one that changed nothing the caller read. A precondition built on * it can refuse a write that would have been safe; it cannot admit one that * would not. That is the correct direction to fail, and it is a real * difference from a per-row counter. * * Rule 3 permits a projection read of `_substrat_*`, so a vertical *could* * hand-roll this `SELECT`. It should not: the spine's schema is private and a * vertical pinned to it is pinned to a table the kernel may re-shape. Same * reasoning as `platformRequests`. * * **Checks no permission** — nothing on `ctx` does. A version is not a read of * the entity, but it is evidence the entity exists, so an operation that hands * one to an untrusted caller does its own `assertAllowed` first. */ versionOf(entity: EntityRef): EntityVersion | null; /** Node-level check; pass `entity` for per-entity checks (portal access, §4.2 rule 3). */ check(permission: PermissionKey, entity?: EntityRef): Promise; /** * Find entities of one type by what a person typed (#827) — the read a picker * over 40 000 customers needs and `ctx.sql` cannot express without every * vertical hand-rolling an index. * * Answers from the FTS5 index the kernel derives from `manifest.searchables`, * maintained by triggers, so it sees a row the same transaction wrote. Returns * **ids and ranks only** — the row shape is the module's own, so hydrate the * hits through the read path that already exists rather than growing a second * answer to "what is a customer". * * ```ts * const hits = ctx.search('customer', term, { limit: 10 }); * const rows = ctx.sql.query( * `SELECT * FROM callout_customers WHERE id IN (${hits.map(() => '?').join(',')})`, * hits.map((h) => h.id), * ); * ``` * * **This does not check permission** — nothing on `ctx` does. The operation's * own `assertAllowed` still comes first, and an entity-narrowed vertical has to * filter the hits it hydrates: a ranked top-N filtered afterwards returns FEWER * than N, so over-fetch deliberately rather than discovering it at a customer * whose picker looks half-empty. * * Throws `SearchTermTooShort` for a term below the index's floor and * `NotSearchable` for an entity type no module declared — never an empty array * standing in for a misconfiguration. */ search(entityType: string, term: string, options?: SearchOptions): SearchHit[]; /** * Read one PAGE of a declared entity (#811, K-18) — the kernel-composed half of * a paged read. * * Composes the `WHERE` from the operation's declared `filterable` columns, the * `ORDER BY` from the caller's choice among `sortable`, the keyset comparison, * the `LIMIT`, and — when the declaration asks for a total — the `COUNT` over * that same `WHERE`. It reads the indexes it also provisioned, so a declared * filter is an indexed one rather than a table scan waiting for a big tenant. * * Returns **rows**, wrapped in a page. The projection stays the module's: map * with `mapPage` to keep the cursor and total while re-shaping the entries. * * ```ts * const page = ctx.page('workorder', { * limit, cursor: input.cursor, sort: input.sort, filters: { status: input.status }, * }); * return mapPage(page, toWorkOrder); * ``` * * **This does not check permission** — nothing on `ctx` does, and a paged read * is not an exception. The operation's own `assertAllowed` still comes first. * A read that filters per ROW after the fact (a portal walk) cannot use this at * all: a page of 20 filtered down to 3 is not a page, and the honest shape is an * over-fetch loop the handler owns. * * Throws `NotListable` for an entity no operation declared `paged.over` on, * `SortNotDeclared` for a `?sort=` outside the vocabulary, and * `FilterNotDeclared` for a filter outside it — never a silently-ignored * parameter, which is how a caller comes to believe a filter is applied. */ page(entityType: string, params: PageParams): Page | CountedPage; /** * Read one of the tenant's currently-held entitlements at request time (#304) — the * sanctioned way a hosted vertical gates a feature or enforces its own quota WITHOUT a * control-plane binding. Returns the live view (`key`, `plan`, `quota`, `expiresAt`) or * `null` when the tenant does not hold the key or the grant has expired — expiry is applied * at read, so a non-null result is always live. `plan`/`quota` are expression only: the * kernel enforces presence + expiry (that is the per-operation entitlement gate), the * vertical decides what `quota` means. On a hosted vertical this reads the scope-local * projection (scope-local-permissions.md); on a console-managed one it reads the directory. */ entitlement(key: string): Promise; /** Every entitlement the tenant currently holds (expired grants excluded), as read views. */ entitlements(): Promise; /** * Seal a value TO a connector, so it can ride the spine without being IN the * spine (#687, design/signature-contact-carrier.md Option E). * * The problem this solves is narrow and had no other answer. A vertical * sometimes has to hand a connector something the platform must not keep — how * a signatory is reached, so a document that starts has somebody to go to — * and every channel from a scope to a connector is a spine row: the outbox, * the platform-request payload. Both are kernel rows a vertical may neither * write nor erase (rule 3), so anything a hosted vertical emits in plaintext * stays plaintext in copies it cannot reach. Encrypting in-scope does not help * by itself — a symmetric key would have to travel the same rows as the value. * * What works is that the CONNECTION holds a keypair whose public half is * projected down. Seal here, put the cell on the event, and the connector opens * it at egress with a private half that never left the directory. Nothing * re-enters the scope actor, so the fat-event rule survives intact: the * consumer still needs no cross-module read, it just cannot read one field. * * `provider` is what module code knows ('scrive'), never a connection id — * connection identity is the host's business, and an engine that learned it * would be naming infrastructure it is not allowed to see. * * **Fails closed and legibly.** Throws `ConnectionSealingKeyUnavailableError` * when no key for that provider has been projected into this scope. Returning * an unsealed value, or silently dropping the field, would emit a request that * reaches nobody — which is exactly the invisible failure this carrier exists * to end (§7 point 2). * * Awaiting this BEFORE `ctx.emit` is what keeps `emit` synchronous: an * operation is `async`, Web Crypto is not, and D-28 stays untouched. */ sealToConnection(provider: string, plaintext: string): Promise; /** * Record a relation tuple child→parent (K-16) — the write path for the * permission evaluator's entity-edge rule (design doc §4.2 rule 3). The * relation must be declared in some registered module's `entityRelations`. * Idempotent. */ link(child: EntityRef, parent: EntityRef): void; /** * Narrow a permission the CALLER ALREADY HOLDS onto one entity — how an app * expresses user-initiated sharing. * * Every entity-narrowed grant in the fleet used to be made at seed time * through `HostAdmin.grant`, which is a platform actor's verb. An app where a * person shares their own record with someone therefore had no supported * mechanism: the alternative is a membership table consulted by hand in every * handler, which is the forgotten-WHERE-clause failure this platform exists to * remove. * * Non-escalating by construction: * * - `entity` is REQUIRED — module code can never write a scope- or * tenant-wide grant, only narrow one onto a thing. * - The caller's own decision on that entity is re-checked, so an operation * can only hand out what it was itself given. Delegation, never elevation. * * Transactional with the operation: a grant made by an operation that then * throws never happened, the same as its rows and its events. */ grant(principal: PrincipalId, permission: PermissionKey, entity: EntityRef): Promise; /** Withdraw a grant this caller could have made. Same guardrails. */ revoke(principal: PrincipalId, permission: PermissionKey, entity: EntityRef): Promise; /** * May the CALLER confer `roleKey` at this node? (K-21, membership.md §5.1.) * * *A principal may assign role `R` at node `N` only if the assigner already holds * every permission `R` carries at `N`.* Without that bound, the D-22/D-29 checkpoint * that reviews role DEFINITIONS protects nothing: an `admin` assigning themselves * `owner` widens no role, calls no `defineRole`, and appears in no permission diff. * * **Removal takes the same bound.** A junior admin who can strip a role they could not * have granted can lock the owner out of their own tenant; revocation is the mirror of * assignment, not a lesser act. So gate both sides on this. * * ```ts * const bound = await ctx.canAssign(input.roleKey); * if (!bound.covered) { * throw substratError('forbidden', `cannot assign ${input.roleKey}: missing ${bound.missing.join(', ')}`); * } * ``` * * **This is a bound, not the permission check.** The operation still opens with its own * `assertAllowed(await ctx.check('member:manage'))` — that answers *may you manage * members at all*, where this answers *may you confer this much*. Both, in that order. * * Narrowing-aware: an entity-narrowed grant does not satisfy the bound for the * unnarrowed permission, or sharing one record would launder into authority over every * record by way of assignment. Membership does expand — authority held through an org * is authority that can be conferred. * * Throws when `roleKey` names no role in this tenant: an unknown key is a bug in the * caller, not a denial, and returning "not covered" would let a typo read as a * permission problem. */ canAssign(roleKey: string): Promise; /** * Run `fn` as a SUB-TRANSACTION of this operation (#770, * docs/architecture/sub-transactions.md) — the boundary that makes catching an * engine error safe. * * A vertical composes engine in-scope functions inside one scope transaction, * and without this the adapter rolls back only when the whole handler throws. * So a vertical that did the reasonable thing — catch a `completeWorkOrder` * failure, fall back to a manual path — committed the engine's partial writes, * which are exactly the ones its invariants were protecting. * * Inside `atomic`, a throw discards everything `fn` wrote — rows, events, * links, grants and platform intents alike — and the ORIGINAL error is * rethrown unwrapped. The caller's own writes, before and after, survive, and * the operation still commits once. * * Two things it deliberately does not promise: * * - **The commit is provisional.** If the operation later throws, a succeeded * `atomic`'s writes are discarded with everything else. This narrows what a * CAUGHT error destroys; it never promotes writes past the operation's own * commit. * - **Not every storage failure is recoverable.** Ordinary constraint * violations are; conditions that abort the enclosing transaction outright * (`SQLITE_FULL`, `SQLITE_BUSY`, an explicit `ON CONFLICT ROLLBACK`) are not. * * Nests. Sub-transactions must not INTERLEAVE, though — starting two * concurrently (`Promise.all`) throws rather than crossing savepoint frames. * * Outside `ctx.atomic`, catching an engine error remains forbidden: the writes * are still there, and on a host whose transactions poison on error (Postgres) * the operation is already unrecoverable. */ atomic(fn: () => T | Promise): Promise; } export type OperationHandler = (ctx: OperationContext, input: I) => O | Promise; /** * The per-invocation transport channel: what the caller requires to be true * before the operation runs, and the transport facts it needs back (#129, #116). * * A separate parameter rather than fields on the input, because these are facts * about the REQUEST and not about the domain. A handler's declared input is what * the operation MEANS, and threading a retry token or an entity tag through it * would make every in-process caller state something it does not have. * `mountOperations` reads them off headers; a test, a seed or a schedule omits * them entirely. * * Per INVOCATION rather than on `ScopeStubOptions`, where `onPlatformRequests` * lives, and the difference is not stylistic. A stub is minted by the vertical's * own `resolveStub`, so anything hung off it requires that vertical to cooperate; * `If-Match` and the `ETag` are wholly the mount's business and must work with no * change to a vertical at all. They are also genuinely per-call — one stub serves * one request, but nothing in the contract says so. * * Deliberately one bag rather than a parameter per concern. `If-Match` and * `Idempotency-Key` are ONE precondition pass at one point in the invoke — before * the guards, inside the transaction — which is what #116's note asked of * whichever landed first. #129 built the bag; #116 declared into it and added no * second interception point. */ export interface InvokeOptions { /** * The version the caller believes it is writing over, verbatim from `If-Match` * (quoted, and possibly a list — `ifMatchAdmits` owns the parsing). * * Honoured only by an operation that DECLARES `concurrency`. Sending it to one * that does not is an error rather than a no-op: a caller who believes it is * protected and is not is the failure this whole mechanism exists to prevent, * and silence is exactly how that belief survives. */ readonly ifMatch?: string; /** * Called after a guarded operation COMMITS, with the entity's version as it * stands at commit — the `ETag` the transport hands back. * * Read after the handler and inside the same transaction, so the tag describes * the row as the caller's own write left it rather than as the caller found it. * A client that echoed back what it sent would loop on its own stale value. * * Never called for a rolled-back operation, and never for an operation that * declares no `concurrency`: a version that did not survive its transaction is * not a tag anyone may hold, and an operation that opted out must not pay for a * spine read on every invocation. */ readonly onEntityVersion?: (version: string | null) => void; /** * The client's retry token, verbatim from `Idempotency-Key` (#116). * * Honoured by every operation on an unsafe method — there is no declaration to * make, because a retried write creating a second entity is a hazard on all of * them. The exception is an operation that declared `idempotency: false`, whose * response must not be recorded; sending a key to one is an error rather than a * no-op, for the same reason an unhonoured `If-Match` is. * * A first request under a key runs, and its return value is recorded inside the * operation's own transaction. A second request under the same key returns that * recording without running the handler. A second request under the same key * with a DIFFERENT input is refused — a key names one request, and serving the * first one's response to a second one would be a lie a client acts on. */ readonly idempotencyKey?: string; /** * Called when this invocation was answered from a recording rather than run. * * The transport sets `Idempotency-Replayed` from it. Advisory: a caller that * ignores this is not wrong about anything, it simply cannot tell a retry from * a first request — which is enough of a debugging cost to be worth a callback. */ readonly onIdempotentReplay?: () => void; /** * An id for the INVOCATION this call belongs to (#1237), minted by the transport. * * The spine records what each event was caused by (#1437) and which operation * emitted it (#1231), and still cannot say which two events came from the same * call. That is the join a trace view needs and the one nothing could make: the * runtime's own request id is stamped by the log platform at ingestion, so no * vertical code can read it, and a trace does not cross the dispatch hop * (`invocation-log.ts`, verified in production). * * So the platform mints one, puts it in the invocation log line, and carries it * here — where it is stamped onto every event the call emits. That makes an * invocation's events groupable, and joins them to the line that knows the call's * duration and status. * * Advisory and optional: absent, events carry no invocation id and read as * unrecorded, exactly as they did before. It is never a permission or a filter — * a caller inventing one can group its own events and nothing else. */ readonly invocationId?: string; } /** The capability stub — the ONLY way code outside the scope reaches it. */ export interface ScopeStub { readonly tenantId: TenantId; readonly scopeId: ScopeId; invoke(operation: string, input?: I, options?: InvokeOptions): Promise; } /** * Observers a caller may attach when minting a stub (#458). Harness-level, not * module-level: module code never sees these — they exist so the HTTP layer * around an operation can react to what the operation did without the module * carrying a new surface. */ export interface ScopeStubOptions { /** * Fired after an invoke through this stub COMMITS having enqueued platform * requests via `ctx.requestPlatform`, with how many. A vertical's request * handler uses this to flag its response `x-substrat-platform-request` * (`PLATFORM_REQUEST_HEADER`) so the router kicks an immediate drain of this * scope (#381) — p50 provisioning latency drops from sweep-cadence to seconds. * Never fired for a rolled-back operation: an intent that did not survive its * transaction is not a signal. Purely advisory — a missed callback costs one * sweep interval, nothing more. */ onPlatformRequests?: (count: number) => void; } export interface SqlMigration { /** Ordered, unique per module, e.g. '0001-init'. Journaled per (module, version). */ version: string; sql: string; } /** * The deployed migration frontier — what "up to date" means for THIS host build * (kernel-design §5.3, #49). `total` counts the registered (module, version) * pairs, which is exactly the number `scope.schemaVersion` counts toward, so * "which scopes are behind" is a directory comparison and never a fan-out * (§5.4). In a multi-deployment fleet each deployment has its own frontier — * this describes the modules registered on this host, nothing more. */ export interface MigrationFrontier { total: number; } /** * What one deliberate migration attempt did (`migrateScope`, the sweep's retry * affordance — #49). * * A structured result, NOT a throw, and deliberately so: the wake paths * (`getScope`, `invoke`) must keep rejecting so a half-migrated scope fails * closed on every operation (#50's near-regression), but the sweep is not a * request — a failure is a state it reports and backs off from, not an * exception. `noop` means this host had nothing pending for the scope: either * it is at the frontier already, or the scope's modules live in a different * deployment (the control plane sweeping a fleet it does not run) — in both * cases no state was touched, so a foreign host can never clear a failure it * knows nothing about. */ export type MigrateScopeOutcome = { status: 'migrated'; schemaVersion: string; } | { status: 'noop'; } | { status: 'failed'; failure: { version: string; error: string; }; }; /** * How a module (engine or vertical) joins a host: manifest + migrations + * operations in one registration. Migrations apply lazily per scope, inside * the scope's serialization domain, journaled in `_substrat_migrations` * (design doc §5.3 in miniature). Operations are the module's default * bindings (K-16); in-scope functions need no registration — they are plain * exports called by other modules' handlers. */ /** * Event consumers run as ordinary in-scope operations under a system actor, * delivered at-least-once (kernel delivery journal); handlers must be * idempotent. Ordering is guaranteed only within (scope, module) — K-11. */ export type ConsumerHandler = (ctx: OperationContext, event: DomainEvent) => void | Promise; /** * An **executor**: out-of-band host code that effects, outside a scope, what a module * asked for inside one (K-22 §4.2; D-18's triage rule — effects on the outside world * are connectors). * * Why this rather than an in-scope capability: some effects are not scope-local. * Membership tuples are tenant-wide and live in the directory, so an in-scope 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. * * The connector has no such hazard: the module's `ctx.emit` commits WITH its domain * write, so a rollback leaves no event and nothing to effect. The executor then runs * at-least-once from the outbox — so handlers must be idempotent, exactly as * consumers must. * * It receives `HostAdmin`, not `ctx`: it acts with platform authority, which is * precisely what module code must never hold. Admin writes it makes are stamped with * the causing event's id (`causedBy`), so the split trail joins. */ export type ExecutorHandler = (admin: HostAdmin, event: DomainEvent) => void | Promise; /** * How hard the host tries before it gives up on one delivery (#100). * * Defaults suit a directory write. A connector making an outbound HTTP call * wants a longer tail — that is the whole reason this is per-executor rather * than a host-wide constant. */ export interface ExecutorRetryPolicy { /** Total attempts including the first. Reaching it dead-letters. Default 5. */ maxAttempts?: number; /** First backoff step; doubles per attempt. Default 1000ms. `0` retries at once. */ baseDelayMs?: number; /** Ceiling on the doubling. Default 300_000ms (5 min). */ maxDelayMs?: number; } /** * What one drain pass did. `retrying` and `deadLettered` are the numbers a * health surface reports; a caller that ignores them learns nothing, which is * the failure mode the old silent path had. */ export interface ExecutorDrainReport { attempted: number; delivered: number; /** Failed, still under `maxAttempts` — scheduled for a later pass. */ retrying: number; /** Failed at `maxAttempts` — terminal, and the row keeps the last error. */ deadLettered: number; /** * Connector deliveries a CP-less host turned into `connector:` platform * intents instead of running (#574 phase 3). Counted separately from `delivered` * because the effect has not happened yet — the platform's drain owns it now. A * harness treats a non-zero count like `ScopeStubOptions.onPlatformRequests`: flag * the response so the router kicks an immediate drain. */ routedToPlatform?: number; } /** * A module's recurring-work declarations, as `registeredSchedules` reports them * (#383): the module id the sweep runs `runDueSchedules` against, plus the declared * schedules. No vertical: a vertical's runtime serves only its own scopes, and the * module-less control-plane host registers nothing, so the sweep enumerates active * scopes once and runs each registration on each — no cross-vertical reach exists to * filter out. */ export interface ScheduleRegistration { moduleId: ModuleId; schedules: ScheduleSpec[]; } /** A module's declared freshness expectations (#1232), as the host registered them. */ export interface FreshnessRegistration { moduleId: ModuleId; freshness: FreshnessSpec[]; } /** * What `checkFreshness` decided to RECORD for one scope in one pass (#1232). * Already gated: the evaluator writes on verdict CHANGE or on the * `FRESHNESS_HEARTBEAT_MINUTES` heartbeat, so every entry here is a row — * a pass with nothing newly true reports an empty list, and that emptiness * stays unambiguous because the heartbeat clock bounds it. */ export interface FreshnessReport { checks: { eventType: string; /** ok = evidence within the window; failed = stale; skipped = never observed. */ outcome: 'ok' | 'failed' | 'skipped'; /** The newest matching event at evaluation time; null = never observed. */ observedAt: string | null; withinHours: number; }[]; } /** * What `runDueSchedules` did for one scope in one pass (#383). A schedule inside its * cadence window is `skipped`; a due one is `fired` (its operation ran) or `failed` * (the operation threw — recorded, never allowed to stop the others). */ export interface ScheduleRunReport { fired: number; skipped: number; failed: number; /** Per-schedule failures on this scope: the operation name and the error. */ errors: { operation: string; error: string; }[]; /** * Per-schedule outcomes, in declaration order (#1232) — what the durable sweep * record is written from, since the counters above cannot say WHICH schedule * fired. Optional so a pre-widening fake or a stored report stays valid; both * adapters' drivers always fill it. */ runs?: { operation: string; outcome: 'ok' | 'failed' | 'skipped'; }[]; } /** * The web-standard fetch surface, structurally typed. * * Declared rather than imported: the kernel depends on no platform typings, and * `RequestInit`/`Response` come from DOM lib in Node and from workers-types in * Workers. Structural typing means both satisfy this without either being * required — the same reason `crypto` and `TextEncoder` are declared locally. */ export interface FetchLike { (input: string, init?: ConnectorRequestInit): Promise; } /** * The runtime's own `fetch`, as a `FetchLike` — the ONE place the structural cast * lives. A host that takes a `fetch` defaults to this; a caller that hands one to a * connector passes this. Nobody else spells `globalThis.fetch` (`lint:bound-fetch`). * * The cast is needed at all because a real `fetch` is NOT assignable to `FetchLike` * under strict TypeScript: `ConnectorRequestInit.body` is a `Uint8Array` the DOM lib's * `BodyInit` no longer accepts unparameterized, and `signal` is `unknown`. Structurally * the runtime satisfies it — every field is one `fetch` reads — and this is where that * claim is made, once. Code typed against the DOM `fetch` itself (a `Response` in hand) * does not want this; `globalThis.fetch.bind(globalThis)` is that type with no cast. * * An arrow, deliberately, rather than the global handed on as a value: workerd * refuses the bare global invoked with any other receiver — `TypeError: Illegal * invocation` — and a callee is free to call what it was given as `input.fetch(…)`. * Node's fetch accepts any `this`, and so does the wrapper the workers vitest pool * installs, so no suite in this repo sees the difference; the Fortnox consent * callback shipped green and failed every hosted round that way (#1291). Closing * over the global here means the receiver is never in play, anywhere downstream. */ export declare const globalFetch: FetchLike; /** * The host's clock (#812) — what `ctx.now()` reads. * * Injectable for the same reason `FetchLike` is: the thing outside the process * that a test cannot otherwise control. A host given no clock reads the wall * clock, which is every production path; a test hands in a frozen or scripted * one and gets a scenario that asserts the interesting case instead of avoiding * it. * * **Which host accepts one is not uniform, and the asymmetry is deliberate** * (#956). `SqliteScopeHostOptions.clock` takes this type, and #1160 made it the * instant the host judges elapsed time against as well as the one `ctx.now()` * reads — tuple expiry in the built-in checker, session expiry, entitlement * expiry, schedule cadence, a migration's `applied_at`. * `CloudflareScopeHostOptions` declares `clock?: never`, because the reads that * matter are DO-local: workerd constructs the ScopeDO, the host holds a stub of * it, and an option on the factory reaches the coordinator half only. A clock * that moved *some* of a host's judgements while carrying the pure adapter's * name would be worse than none, so the type refuses it. This interface is * therefore the seam the pure host honours in full, not a guarantee every host * makes; the cost — expiry transitions asserted on one adapter only — is written * up beside that `never` and in `apps/docs/reference/contract-tests.md`. * * Returns an `Instant` rather than a number so there is exactly one timestamp * format on the way in, and the host never has to guess whether it was handed * seconds or milliseconds. */ export interface Clock { (): Instant; } export interface ConnectorRequestInit { method?: string; headers?: Record; /** * `Uint8Array` as well as `string` because a real provider upload is binary: * Scrive's `setfile` is `multipart/form-data`, whose body is a byte sequence a * string cannot carry without corrupting the file. Web `fetch` accepts both, so * this only widens the declared surface — the adapter passes it straight * through. */ body?: string | Uint8Array; signal?: unknown; } export interface ConnectorResponse { readonly ok: boolean; readonly status: number; text(): Promise; json(): Promise; /** * The raw bytes — for provider responses that are a file, not JSON. Scrive's * sealed signed PDF (`documents/{id}/files/main`) comes back as `application/pdf`, * which `text()` would corrupt. Web `Response` already has this, so declaring it * only widens the structural surface the adapter passes straight through. */ arrayBuffer(): Promise; /** * The response headers, when the runtime behind this seam has them. * * OPTIONAL, and the optionality is the whole design. Every adapter's `fetch` * returns a real web `Response`, which always carries headers — so a connector * reading one gets a value in production, always. What is not guaranteed is a * hand-built stand-in: a mock or an in-memory provider is a plain object * literal, and requiring `headers` would break every one of them for a field * most connectors never read. * * So a caller must handle absence, and the callers that need it do. It exists * because some provider instructions live ONLY in a header and a connector that * cannot read them has to invent a substitute: Planima answers a rate limit with * `Retry-After`, and a client that cannot see it backs off on a schedule of its * own guessing — either too soon (spending another request to be told the same * thing) or too late. Declaring it here is what keeps that read inside the * sanctioned seam instead of a structural cast in a connector. */ readonly headers?: { get(name: string): string | null; }; } /** * A connection with its credential opened, plus egress bound to it. * * `fetch` is bound to the connection rather than sitting on the context because * health has to land on the right row by construction. An ambient * `ctx.fetch` would need the runtime to guess which connection a call belonged * to, and it would guess wrong the first time a connector talked to two. */ export interface ConnectorConnection extends OpenConnection { /** * Sanctioned egress: a timeout, and success/failure recorded against THIS * connection. * * The connector is handed its `fetch` rather than importing one — the same * move `ctx.sql` makes for module code, and for the same reason. Egress * policy, timeouts and health become properties of the seam instead of * conventions a connector author has to remember. Module code still cannot * reach any of this: boundary-lint R3 bans `fetch` outright, and a connector * is host code. */ fetch(input: string, init?: ConnectorRequestInit): Promise; } /** * A connection opened INSIDE a dispatch (#711) — `ctx.connection(provider)`. * * Strictly more than a `ConnectorConnection`, and the difference is a scope. A * dispatch happens *for* one scope, which is ambient in the context, so a * connection opened there can reach that scope's attachments. A connection * reopened top-level — a probe of a candidate credential, a poll driver * rebuilding egress from the directory — has no such scope, and the type says so * rather than handing out a method that would have to throw. */ export interface ScopedConnectorConnection extends ConnectorConnection { /** * Read ONE attachment's bytes from the scope this delivery is for (#711) — the * outbound mirror of `getConnectorAttachments`, which is the return path's door. * * A signing connector sends a document. Until this existed it could only send a * document it RENDERED ITSELF, because `create` had no way to be handed the * vertical's own file: the bytes were in the attachment store, and the store was * unreachable from inside a dispatch. So a Swedish counterparty was asked to sign * a page of identifiers with BankID rather than the contract. * * **On the connection, for the same reason `fetch` is.** The read is authorized as * a connection, and the only connection it can correctly be authorized as is the * one the handler opened — this object. An ambient "the provider this connector is * registered under" would be a second name for the same thing, and two names for * one fact is how they come to disagree: `registerScriveConnector({ id: 'scrive-eu' })` * opens its credential as `'scrive'` and would have read as `'scrive-eu'`, so the * egress half kept working while the document half failed on every contract. * Handing the door to whoever holds the credential makes that unrepresentable. * * **Why not `getConnectorAttachments` from in here.** On the pure adapter a * connector runs INSIDE the scope's actor task, and every verb of that surface * re-enqueues on the same actor — the nested task waits on the task holding it, * and the invoke never returns. (Pinned in `connector-reads.test.ts`.) The adapter * builds this read to suit where it is running: reentrant when the caller already * holds the actor, ordinary and serialized when it does not. * * **Reads only, and only by id.** No `list`, deliberately. A connector that * SEARCHES for the document to send has to have a rule for picking among several * — and the return path lands the sealed signed copy on the same entity, so a * wrong rule mails the counterparty their own signed contract to sign again. * Naming the id makes that unrepresentable: the caller says which bytes, the * event carries it, and nothing has to be disambiguated. Writes stay top-level * (`getConnectorAttachments().upload`), where a spine event and its consumers * have a transaction to live in. * * Gated by the target's `readPermission` checked against this connection's * `connection:` grants. `null` for an id the scope does not know — a caller * falls back rather than failing a dispatch over a missing file. */ openAttachment(attachmentId: string): Promise; /** * What this connection may do in the scope this delivery is for (#726 gap 1) — * `connectionGrantsInScope`, narrowed to this connection. * * Here so a connector can check its own preconditions at the top of a dispatch and * fail saying which grant is missing, instead of discovering it three calls later as * a refusal the drain then captions as the provider's (#841). A connector that needs * a standing grant for its RETURN path — where there is no delivered event to carry * authority — can say so on the way out, which is the only moment it is cheap to fix. * * Deliberately the permission keys and nothing else: a connector asks whether it may * act, never who granted it or when. Both are the operator's question, and both are * on the drawer that already renders them. */ grants(): Promise; /** * Open a cell the scope sealed TO this connection (#687) — the egress half of * `ctx.sealToConnection`. * * **Here rather than on `ConnectorConnection`, and the probe path is why.** A * connection rebuilt top-level may have no directory row at all — the credential * probe builds one from a candidate secret and a zero id — so it holds no * keypair and could only ever throw. A sealed cell arrives on a delivered event, * which is to say inside a dispatch, which is exactly the shape that has one. * * **On the connection, for the same reason `fetch` and `openAttachment` are.** * The private half belongs to one connection, and the only connection it can * correctly be is the one this handler opened. An ambient `ctx.unseal` would * have to guess, and it would guess wrong the first time a tenant held two * credentials for one provider. * * Key material never crosses this seam: the adapter holds the keyId-indexed map * and picks by the cell's own `keyId`, so a connector cannot mislay a private * key it never had. Throws `SealedKeyUnavailableError` when the named key is not * held — which after a rotation-as-erasure (D-5) is the correct and permanent * answer, not a mystery. A scope restored from backup can resurrect a pending * request whose key is gone, and that delivery should dead-letter saying so. */ unseal(sealed: SealedSecret): Promise; } /** * What a connector sees. Strictly more than an executor: an executor effects * something in the DIRECTORY, a connector effects something in the OUTSIDE * WORLD, and only the second needs a per-tenant credential and egress. */ export interface ConnectorContext { readonly admin: HostAdmin; readonly tenantId: TenantId; readonly scopeId: ScopeId; /** The scope's vertical — half the key a connection is stored under. */ readonly vertical: string; /** * The live connection for this provider, opened. * * Resolved as (this event's tenant, this scope's vertical, provider), so a * connector cannot reach a credential another vertical connected even by * accident — the tenant and vertical are ambient, not arguments. Throws when * there is none: a connector that runs without a credential would otherwise * fail later, further from the cause. */ connection(provider: string): Promise; } export type ConnectorHandler = (ctx: ConnectorContext, event: DomainEvent) => void | Promise; /** Tuning for one connector's egress. */ export interface ConnectorOptions extends ExecutorRetryPolicy { /** Per-request timeout. Default 30s. */ timeoutMs?: number; /** * The provider slug this connector operates (#574 phase 3) — the routing key a * CP-less host uses when it cannot run the handler itself: the delivery becomes a * `connector:` platform intent, and the platform's drain dispatches it to * the handler registered for that same slug. Defaults to the registration id, which * for the shipped connectors is already the provider name ('scrive'). Irrelevant on * a host that reaches the connection directory (self-host, the control plane): there * the handler runs in-process and this key is never consulted. */ provider?: string; } /** A delivery that exhausted its attempts. The evidence, not a silent drop. */ export interface ExecutorDeadLetter { eventId: string; executorId: string; eventType: string; attempts: number; error: string; lastAttemptAt: string; } /** * Retry defaults (#100). Tuned for the directory write the first executor does; * a connector making an outbound call should raise `maxAttempts` explicitly, * which is why the policy is per-executor rather than a host constant. */ export declare function resolveRetryPolicy(retry?: ExecutorRetryPolicy): Required; /** * When attempt `attempts` should next be tried: exponential, capped, jittered. * * Jitter is ±20% and is skipped entirely at zero delay, so a test setting * `baseDelayMs: 0` gets deterministic immediate retries rather than a race. It * matters at real delays because every scope in a fleet retries a downed * provider on the same schedule otherwise. */ export declare function backoffAt(attempts: number, retry: Required, from: Date): string; /** * A named, manifest-wired pre-condition on an operation (K-17; engine-protocol * §6, open question 11). One module CONTRIBUTES a predicate under a name; a * (usually different) module's manifest WIRES it to an operation via * `guards: [{ before, predicate, config }]`. The kernel runs it inside the * guarded operation's own transaction, immediately before the handler: * * throw → the operation is BLOCKED and the transaction rolls back (fail closed) * return → the handler runs * * `config` is the manifest's config object, opaque to the kernel and parsed by * the predicate itself; `input` is the (already structured-cloned) operation * input. A predicate is a READ: it must not mutate — it is a gate, not a hook. * Star topology holds — the guarded engine knows nothing of the guarding one. */ export type GuardPredicate = (ctx: OperationContext, config: Record, input: unknown) => void | Promise; /** * What an engine exports so a VERTICAL can consume its events with types (#696): * event type → payload shape, plus any sets of events that report the same fact * by different routes. * * ```ts * export type ProtocolEvents = { * events: { * 'protocol.signed': ProtocolSignedPayload; * 'protocol.countersigned': ProtocolCountersignedPayload; * }; * completionGroups: { signature: 'protocol.signed' | 'protocol.countersigned' }; * }; * ``` * * TYPES ONLY — nothing here exists at runtime. The runtime contract is still the * fat payload and **the consumer's own Zod parse**: importing a producer's * validator is what turns version skew into a crash instead of a tolerated * absence. These types are for the compiler, not the boundary. * * VERTICAL-FACING ONLY. An engine consuming a sibling's event must NOT reach for * this — R1 (star topology) forbids the import, and the defensive parse is what * lets it ride out #128's dual-emit window. `engine-invoicing` consuming * `workorder.completed` with its own Zod view is the correct shape and stays so. */ export type EventContract = { readonly events: Record; /** * group name → the union of event types in it. A consumer that handles one * member must handle all: completion often rides on whichever event happens * to arrive LAST, so handling a subset silently strands the entity. */ readonly completionGroups?: Record; }; type UnionToIntersection = (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never; type AllEvents = UnionToIntersection; /** Every event type the declared engines emit. */ export type EventTypeOf = keyof AllEvents & string; /** The payload an engine declares for one of its event types. */ export type EventPayloadOf> = AllEvents[K]; /** * A consumer whose `event.payload` is the producer's declared shape rather than * `unknown`. Same two-argument signature as `ConsumerHandler`, so this is purely * a narrowing. */ export type TypedConsumerHandler

= (ctx: OperationContext, event: Omit & { payload: P; }) => void | Promise; type AllGroups = UnionToIntersection; }>['completionGroups']>; /** * Every member of any completion group already partly handled. * The `[…] extends [never]` bracketing is load-bearing — a naked `extends never` * distributes and silently yields `never` for every group, disabling the check. */ type RequiredCompanions = { [G in keyof AllGroups]: [Extract[G] & string, Handled>] extends [never] ? never : AllGroups[G] & string; }[keyof AllGroups]; type MissingCompanions = Exclude, Handled>; /** * `unknown` when every completion group is fully handled; otherwise DEMANDS the * missing members, so the compiler names the event that was missed. */ type Completeness = [ MissingCompanions ] extends [never] ? unknown : { readonly [M in MissingCompanions]: TypedConsumerHandler; }; /** eventType → typed handler, for the engines a vertical declares. */ export type TypedConsumers = { readonly [K in EventTypeOf]?: TypedConsumerHandler>; }; /** * The consumer map, typed against the engines a module composes (#696). * * With no declared engines this is exactly what it always was — an untyped * `Record` — so every existing module keeps compiling * unchanged. Declare engines and three things become compile errors: an event * type no declared engine emits, a payload field the producer does not send, * and a completion group handled only in part. */ export type ConsumersOf = [C] extends [readonly []] ? Record : TypedConsumers; /** * Inference site for the completeness check. * * `ModuleRegistration` is an interface, so it cannot see WHICH event keys a * module wrote — and completeness is a question about exactly that. This helper * captures them: * * ```ts * consumers: consumersFor<[ProtocolEvents]>()({ * 'protocol.signed': async (ctx, event) => { … }, * 'protocol.countersigned': async (ctx, event) => { … }, * }) * ``` * * Omit the second and it does not compile: *Property '"protocol.countersigned"' * is missing*. */ type ConsumerMap = { readonly [K in keyof H]: K extends EventTypeOf ? TypedConsumerHandler> : never; }; export declare function consumersFor(): >(handlers: H & Completeness) => H; export interface ModuleRegistration { manifest: ModuleManifest; migrations?: SqlMigration[]; operations?: Record>; /** * name → the schema the host parses an invocation's input against, BEFORE the * guards and the handler see it (#893). * * Derived from the declared operation surface — `operationInputsOf(ops)` — and * never written a second time. A module that declares its operations gets the * parse by handing the same object over: * * ```ts * operations: { 'rally/book': bookOp, … }, * operationInputs: operationInputsOf(rallyOperations), * ``` * * **This is where "parse, don't trust" is kept, rather than in 85 handlers.** * `OperationShape.input` calls itself *"the SAME Zod object the handler * parses"* and across the fleet it mostly was not — rally declared 32 inputs * and parsed 2. One place that cannot be forgotten beats a rule every new * operation has to remember, which is the same argument `mountOperations` * already makes for the page trio. * * A name here that no operation binds is an error: it is a schema enforcing * nothing, and it reads as coverage. A bound operation with no entry is * allowed and means what it always meant — nothing was declared to parse. * * Typed structurally rather than as `z.ZodType` so the kernel keeps its single * dependency and no zod version is pinned by the scope-host contract. The * shape is the whole surface the host uses: throw to refuse, return the value * to accept. */ operationInputs?: Record; /** * name → the entity whose version this operation's `If-Match` is compared * against, and the input field carrying its id (#129). * * Derived from the declared operation surface — `operationConcurrencyOf(ops)` — * and never written a second time, exactly as `operationInputs` is: * * ```ts * operations: { 'callout/update-customer': updateCustomerOp, … }, * operationInputs: operationInputsOf(calloutOperations), * operationConcurrency: operationConcurrencyOf(calloutOperations), * ``` * * **The host compares, not the handler.** A precondition a handler evaluates is * a precondition a handler can forget, and the one that is forgotten is * indistinguishable from one that passed. Here the comparison happens between * `BEGIN` and the guards for every caller and every transport, or the operation * does not claim to have it. * * A name here that no operation binds is an error, for the same reason it is on * `operationInputs`: it reads as coverage while enforcing nothing. */ operationConcurrency?: Record; /** * The operations that declared `idempotency: false` (#116) — the ones whose * response must not be recorded, and which therefore refuse an * `Idempotency-Key` instead of honouring it. * * Derived like the two above, and never written a second time: * * ```ts * operationIdempotencyOptOuts: operationIdempotencyOptOutsOf(calloutOperations), * ``` * * A list of refusals rather than a list of participants, because that is what * the declaration is. Absent means every operation honours a key, which is the * default and the reason there is nothing to remember. */ operationIdempotencyOptOuts?: readonly string[]; /** * eventType → handler; the types must appear in manifest.events.consumes. * * Untyped by default. A vertical that declares the engines it composes — * `ModuleRegistration<[ProtocolEvents]>` — gets typed payloads, rejection of * event types nobody emits, and (via `consumersFor`) rejection of a * half-handled completion group. See `EventContract` (#696). */ consumers?: ConsumersOf; /** * Named guard predicates this module contributes to the host — the code half * of `manifest.guards`. Names are module-namespaced like operations * ('protocol/all-signed'). Predicate names are global: two modules may not * contribute the same name. */ predicates?: Record; } /** * Admin surface for enforcement input (design doc §4; control-plane.md §4.4). * * Every mutation is a control-plane action: it takes a `PlatformActorId` — the * authenticated staff subject, typed distinctly from a tenant `PrincipalId` so * the compiler refuses to confuse them — and writes an append-only audit row * stamped platform-side (actor, action, target, before/after, timestamp). The * actor is never a principal in any tenant, and the record is never supplied by * the caller. This is the one surface that must not be retrofitted (K-20): a * surface that can act without a durable record of who acted is worse than none. * * Locally the actor is a dev stub (control-plane.md §6); real staff auth (SSO, * MFA) gates EXPOSING this surface, not building it — D-16 cashed in. * * The whole surface is ASYNCHRONOUS (every method returns a Promise) because a * durable/remote control plane — e.g. a Cloudflare Durable Object — cannot be * backed synchronously: reads may cross an RPC boundary and writes must await a * durable record before returning. The second adapter surfaced this (D-14); a * synchronous admin interface could not be honoured by anything but an in-memory * store, so the contract is async everywhere. (`registerModule`/`defineOperation` * stay sync — they are code-time bookkeeping, not control-plane state.) */ export interface HostAdmin { defineRole(actor: PlatformActorId, tenantId: TenantId, role: RoleDefinition): Promise; /** * Every role the directory holds, ordered by (tenantId, key). * * Roles were writable and not enumerable: `defineRole` has existed since the * permission model shipped, and nothing could ask what roles exist. That makes * the console's half of the permission checkpoint unbuildable — CI diffs the * roles declared in CODE, and this is the only way to see what a live * deployment actually holds, which is not the same question. * * Directory-local, unlike grants: `_substrat_roles` sits beside the tenant * registry, so this is a read. A grant is a tuple in the scope's own database * and needs §5.4's admin-query RPC — the two are not the same size of problem. */ listRoles(actor: PlatformActorId, filter?: RoleFilter): Promise; assignRole(actor: PlatformActorId, assignment: RoleAssignment): Promise; /** * Revoke a role assignment — the inverse of `assignRole`, same `RoleAssignment` * shape. Tombstones the role tuple (K-21, never DELETE), so the checker stops * resolving it and the assignment stays visible to audit; a later `assignRole` * of the same (principal, role, node) reactivates it. Idempotent: unassigning a * role that was never assigned (or already revoked) is a silent no-op. Takes a * `PlatformActorId` like every admin mutation — the caller's own authority to do * this is decided above the kernel (e.g. the dashboard's manage-members check). */ unassignRole(actor: PlatformActorId, assignment: RoleAssignment): Promise; grant(actor: PlatformActorId, grant: CapabilityGrant): Promise; /** Grant to an organization (portal customers); members reach it via membership tuples. */ /** * Grant a permission to a CONNECTION (#97) — how a connector is allowed to * write back into a scope. * * Deliberately the same shape as every other grant: tuples, tombstoned on * revoke (K-21), visible to `listRoles`-style reads and to the permission * diff. A separate "allowed operations" list on the connection was the * alternative and was rejected — two mechanisms for one gate is worse than * either, and only one of them would have shown up in a review. */ grantToConnection(actor: PlatformActorId, grant: ConnectionGrant): Promise; /** * The tenant's connection grants as the DIRECTORY records them (#592) — what * `grantToConnection` wrote alongside each enforcement tuple. This is the gather * source for provision/reconcile delivery (the platform materializes tenant-wide * rows per scope, the same authoritative channel as `listEntitlements` / * `listIdentityLinks`), and the readable answer to "what may this connection * invoke". Returns LIVE rows only — a grant whose connection was revoked is * tombstoned by the revoke cascade and absent here. */ listConnectionGrants(actor: PlatformActorId, tenantId: TenantId): Promise; /** * Grant a permission to a MODULE's system principal (#383) — how a scheduled * operation is allowed to act on a scope without impersonating a person. * * The scheduler analogue of `grantToConnection`, and the same shape for the same * reason: one grant mechanism, tuples tombstoned on revoke (K-21), visible to the * permission diff. It is what makes `ctx.check` resolve for a schedule — the gate * stays `ctx.check`, not a bypass. Projected at scope provisioning from the * module's declared `schedules[].permissions`; a per-tenant "scheduling off" is a * revoke of this grant, nothing more. */ grantToSystem(actor: PlatformActorId, grant: SystemGrant): Promise; grantToOrg(actor: PlatformActorId, orgId: OrgId, permission: PermissionKey, node: Node, entity?: EntityRef): Promise; addMember(actor: PlatformActorId, tenantId: TenantId, principal: PrincipalId, orgId: OrgId): Promise; /** * Revoke a membership (K-21). **Tombstones, never deletes**: the tuple keeps its * row, gains a `revokedAt`, and the permission walk skips it. Deletion would * destroy the audit property K-4 rests on — a tuple that once granted access is * evidence of why an access was allowed — and D-32's operated compliance product * has to produce exactly that evidence. * * Idempotent: revoking an already-revoked or never-existing membership is a * no-op, and a no-op is not audited. Re-adding via `addMember` clears the * tombstone (they are a member again); the add/revoke history lives in the admin * log, which is append-only. */ removeMember(actor: PlatformActorId, tenantId: TenantId, principal: PrincipalId, orgId: OrgId): Promise; /** * The members of an org. Live members only unless `includeRevoked` — the * revoked rows are the evidence view, not the roster. * * Answering "who has access to this org" at all is new: membership was * write-only before this (#34). */ listMembers(actor: PlatformActorId, tenantId: TenantId, orgId: OrgId, options?: { includeRevoked?: boolean; }): Promise; /** * Register an org. Idempotent on the id — re-creating is a no-op, not an error * (as `createTenant`). Slugs are unique within the tenant; a collision from a * DIFFERENT id fails closed rather than silently doing nothing. * * Membership and `grantToOrg` both refuse an org that does not exist here. That * refusal is the point of the record: before it, `addMember(…, 'acme')` and * `addMember(…, 'Acme')` silently addressed two different orgs and a typo in a * grant reached a phantom nothing would ever resolve to. */ createOrg(actor: PlatformActorId, input: CreateOrgInput): Promise; listOrgs(actor: PlatformActorId, tenantId: TenantId): Promise; getOrg(actor: PlatformActorId, tenantId: TenantId, orgId: OrgId): Promise; /** * Register a vertical. Idempotent on the slug; a conflicting re-registration * (different source) throws rather than silently rebinding what a scope runs. */ registerVertical(actor: PlatformActorId, input: RegisterVerticalInput): Promise; /** Ordered by slug; `page.cursor` is a slug. Unset limit = everything (pagination.ts). */ listVerticals(actor: PlatformActorId, page?: ListPage): Promise; /** * Publish a version. It lands **pending** — a push is not a deploy — with ONE * exception: a **private** vertical's version (owned by a tenant, not `listed`) * lands **admitted**, noted `AUTO_ADMISSION_NOTE`. Its blast radius is the owning * tenant alone, and the sandbox contract — not a staff read of an opaque digest — * is what protects the platform, so staff admission there gated nothing dev/staging * didn't already concede. Staff review holds where the audience widens: a listed * vertical's pushes land pending, and `setVerticalListed` refuses an auto-admitted * prod version. * * The digests are what promotion compares. `boundary-lint` and the migration and * permission diffs are the admission gates, and binding a scope is a separate step * (`bindScopeVersion`), so the two human checkpoints fire where the blast radius is * rather than where the typing was. */ publishVersion(actor: PlatformActorId, input: PublishVersionInput): Promise; /** Ordered by id (ULID = publish order), `asc` unless `page.order` says otherwise. */ listVersions(actor: PlatformActorId, verticalSlug: string, page?: ListPage): Promise; /** * One version by id — the read almost every caller actually wanted. * * Before this existed, "the version with this id" was spelled as an unpaginated * `listVersions(slug)` followed by `.find()`: every version a vertical had ever * published, each carrying its stored manifest, pulled across the adapter boundary to * keep one. That cost grows once per push and lands on the paths least able to afford * it — the deploy handler's own read-back, and the router's per-request resolution of * which script serves a scope. * * `verticalSlug` preserves what the old `.find()`-inside-a-slug's-list spelling gave * for free: pass it and a version belonging to a DIFFERENT vertical reads as absent * rather than being returned across the lineage boundary. Fail closed, like `getScope`. */ getVersion(actor: PlatformActorId, versionId: string, verticalSlug?: string): Promise; /** * Admit a pending version — the gates passed. Idempotent on an already-admitted one, * EXCEPT an auto-admitted one (`AUTO_ADMISSION_NOTE`), which it upgrades to a manual * vouch by clearing the note — the recorded human decision `setVerticalListed` requires. */ admitVersion(actor: PlatformActorId, versionId: string): Promise; /** Reject a pending version, with the reason. Rejected is terminal: publish a new one. */ rejectVersion(actor: PlatformActorId, versionId: string, note: string): Promise; /** * Publish/unpublish a vertical to the PUBLIC marketplace (marketplace-publish.md §5) — the * staff admission of a publish request. Flips the registry `listed` flag; `availableCatalog` * then offers it to every tenant (a private vertical shows only to its owner). Staff-only, * idempotent, audited. Distinct from `admitVersion` (servable) and prod promotion. * * **Refuses `listed: true` while the prod channel points at an auto-admitted version** * (`AUTO_ADMISSION_NOTE`): listing is the moment other tenants start trusting this code, * so the version they would install must carry a real staff vouch — `admitVersion` it * first (which clears the note), then list. */ setVerticalListed(actor: PlatformActorId, slug: string, listed: boolean): Promise; /** * A builder REQUESTS that their vertical be published (marketplace-publish.md §5) — records * a pending request for staff to review, without listing it. Ownership is checked at the * control-plane edge (the owning tenant); this records the request + timestamp. Idempotent * (re-requesting refreshes the timestamp). `setVerticalListed` resolves it either way. */ requestPublish(actor: PlatformActorId, slug: string): Promise; /** * Block (or unblock) NEW installs of a vertical — the staff kill-switch for one * that should take no more instances. Orthogonal to `setVerticalListed` * (visibility): a blocked vertical is hidden from the install catalog and * provisioning an instance of it is refused, for everyone including its owner. * Existing scopes keep running untouched — this gates provisioning, not serving. * Staff-only, idempotent, audited. */ setVerticalInstallsBlocked(actor: PlatformActorId, slug: string, blocked: boolean): Promise; /** * Grant (or revoke) the TENANT-PROVISIONER capability (#412) — whether this * vertical's scopes may enqueue `provision-tenant` / `set-entitlements` intents * that the platform executes with its own authority. A directory-backed grant * rather than deployment config, so granting a manager is an audited staff * action, not an env edit + redeploy. Read at drain time by the platform-intent * handlers; flipping it never touches running scopes. Staff-only, idempotent, * audited. */ setVerticalTenantProvisioner(actor: PlatformActorId, slug: string, granted: boolean): Promise; /** * Grant (or revoke) the EMAIL-SENDER capability (#303) — whether this vertical's * scopes may POST to the control plane's `/internal/email/send` relay and have * transactional mail (password-reset, verification, invites) sent on their behalf. * A directory-backed staff grant rather than deployment config: outbound is a * platform concern (a hosted dispatch script cannot bind `send_email` and the §4 * sandbox refuses it), so the platform holds the Email Sending credential and this * flag decides who the relay will send for. Read by the relay handler on every send; * flipping it never touches running scopes. Staff-only, idempotent, audited. */ setVerticalEmailSender(actor: PlatformActorId, slug: string, granted: boolean): Promise; /** * Delete a vertical from the registry — its row, its versions, its channels. * * **Refuses while any scope is still bound to it** (`scopes.vertical`), because a * deleted registry row would strand those scopes' version pins and routing. Delete * or rebind the scopes first; the refusal names the count. Deployed dispatch * scripts are NOT reaped here — they become orphans for the cleanup script (#248), * so a mistaken delete never destroys a deployment that scopes may still need * while the refusal above is being raced. Staff-only, audited. */ deleteVertical(actor: PlatformActorId, slug: string): Promise; /** * Promote a version to a channel (#31 step 2) — the moment a change reaches * anyone, and therefore where §4's two human checkpoints belong. * * **Refuses when a digest differs and the change is not acknowledged.** The * migration and permission diffs are a merge-time convention today: CI renders * them and a human is expected to look, but nothing ties that looking to the * moment of exposure. Here it is tied — and the acknowledgement is recorded, so * "someone reviewed it" becomes evidence rather than a claim. * * Only admitted versions may be promoted, for the same reason they are the only * ones bindable. */ promoteVersion(actor: PlatformActorId, verticalSlug: string, channel: ChannelName, versionId: string, acknowledge?: PromotionAcknowledgement): Promise; /** Ordered by channel name; `page.cursor` is a channel name. */ listChannels(actor: PlatformActorId, verticalSlug: string, page?: ListPage): Promise; /** * The promotion timeline (append-only, newest first) — every pointer move * `promoteVersion` ever made for the vertical, optionally narrowed to one channel. * Rollback UIs pick a target from it, and each entry's `at` is the instant a PITR * restore would rewind the data to (preview-and-snapshots.md §7). */ listChannelHistory(actor: PlatformActorId, verticalSlug: string, channel?: ChannelName, page?: ListPage): Promise; /** * Point a scope at a version. * * **Refuses anything not admitted.** That refusal is the registry's reason to * exist: without it "push lands pending" is a convention, and a convention is what * D-30's lockstep-upgrade argument says we cannot afford to rely on. * * `opts.snapshot` opts into fork-before-promote (preview-and-snapshots.md §4): when * the incoming version's `migration_digest` differs from the scope's current bound * version's, an `archive` snapshot of the pre-migration data is captured first, so a * bad upgrade has a rollback point. Gated on the digest change (a code-only rebind * snapshots nothing) and opt-in until retention/GC ships. */ bindScopeVersion(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, versionId: string, opts?: { snapshot?: boolean; }): Promise; /** * Record that this scope's PROVISION has run against `versionId` (#1172). * * The receipt behind `provisionedVersionId`, and the reason that field is not just * `verticalVersionId` under another name: binding a version is the platform pointing a * scope at code, while this is the vertical's own `onProvision` hook having actually * run against it. A vertical mints its service principals there, and that hook fires * once per scope — at install — so without a receipt the platform cannot tell an * install whose hook has run from one serving code it has never provisioned for. * * Written only after a provision or reconcile SUCCEEDS. Marking optimistically would * silence the sweep for a repair that failed, which is the one case that must keep * being retried. */ markScopeProvisioned(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, versionId: string): Promise; /** What the serving script currently runs, or null before the first in-place serve. */ verticalServing(actor: PlatformActorId, verticalSlug: string): Promise; /** * Record a successful in-place serve: the script name, the version it now runs, * and the DO-class/migration-tag base the NEXT upload diffs against. Written only * AFTER the upload succeeded — a failed serve leaves `servingVersionId` trailing * the prod channel, which is the visible, retryable state. Audited. */ setVerticalServing(actor: PlatformActorId, verticalSlug: string, state: VerticalServingState): Promise; /** * The pushed DeployManifest (JSON) of one version — what a serve rebuilds upload * metadata from. Null for a version pushed before manifests were retained; such a * version can be bound per-version but never served in place. */ versionManifest(actor: PlatformActorId, verticalSlug: string, versionId: string): Promise; /** * Point a scope's ROUTING at the serving script its data now lives in. Per-scope * truth, deliberately not derived from the vertical: rerouting a scope whose DOs * still sit in a per-version script would resolve empty storage. Set at provision * (a scope born on the serving script) or by adopt-serving (a legacy scope whose * data was exported → restored into the serving script). `null` reverts to * per-version dispatch — the adopt path's own backout. Audited. */ setScopeServingRef(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, servingRef: string | null): Promise; /** * Move a fork's GC deadline (preview-and-snapshots.md §9). The reap sweep deletes any * fork whose `expiresAt` has passed, so a long-lived preview reused across many CI * pushes must have its deadline pushed forward on each reuse — otherwise it dies 72h * after its FIRST creation regardless of activity. `null` pins the fork until it is * deliberately deleted (the "absent = pinned" the snapshot body already models). Audited. */ setScopeExpiresAt(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, expiresAt: string | null): Promise; /** * When each of a CO-LOCATED scope's migrations actually ran (#1236), newest * first. `_substrat_migrations.applied_at` has been written since the table * shipped and read by nothing — every reader wanted only the frontier — so a * scope could not answer "when did this schema change", the annotation release * health wants. For a dispatch vertical the route reads it through the * vertical's `/internal/migrations` instead; this is the co-located fallback. */ scopeAppliedMigrations(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId): Promise; /** * The PITR bookmarks a CO-LOCATED scope recorded before its migration passes * (#286) — the rewind points a backout offers. For a dispatch vertical the route * reads them through the vertical's `/internal/bookmarks` instead; this is the * bare-host/co-located fallback. Hosts without PITR (the SQLite adapter) return * an empty list — there is nothing to offer, not an error. */ scopeMigrationBookmarks(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId): Promise<{ bookmark: string; takenAt: string; pending: string[]; }[]>; /** * #286's backout: PITR-rewind a scope to a pre-migration bookmark — schema AND * data, discarding every write since. Audited (destructive by design). The scope * DO enforces the freshness window (24h unless `force`). `localApply: false` * audits without touching this host's own namespace — the route sets it when the * rewind is delegated to a dispatch vertical's `/internal/rewind`, whose DO * actually holds the data. Hosts without PITR throw. */ rewindScope(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, bookmark: string, opts?: { force?: boolean; localApply?: boolean; }): Promise<{ rewindingTo: string; }>; /** * Bind a hostname to a scope's surface. Lands `pending` — a custom domain is DNS * validation and certificate issuance, not a string somebody sets, so the states * it passes through are §4.2's business. * * Exactly one hostname per (scope, surface) may be canonical; binding a second * canonical demotes the first, because "which one do certs and redirects use" has * to have one answer. */ bindHostname(actor: PlatformActorId, input: BindHostnameInput): Promise; setHostnameStatus(actor: PlatformActorId, hostname: string, status: HostnameStatus, note?: string): Promise; /** * Record the outcome of a Cloudflare-for-SaaS issuance step (§4.7) — status plus the * CF custom-hostname id and the DNS records the tenant must publish. This is what the * control-plane's issuance path (bind of a custom domain) and the reconcile poll write * through; a plain status flip stays `setHostnameStatus`. The `customHostnameId` is * written on create and left untouched (`undefined`) on later polls, so the handle a * poll needs is never lost. */ setHostnameIssuance(actor: PlatformActorId, hostname: string, fields: { status: HostnameStatus; note?: string | null; customHostnameId?: string | null; validationRecords: DnsRecord[]; }): Promise; /** * Remove a hostname binding — the inverse of `bindHostname`. * * A hard DELETE, not a tombstone, and deliberately so: a hostname row is * routing config, not access evidence — `deleteSnapshot` already hard-deletes * a reaped fork's rows via the same path, and the bind/unbind history lives in * the append-only admin log (K-21 protects tuples, not the route table). * Idempotent: unbinding an unknown hostname is a silent no-op, not an error — * so a cleanup pass can re-run over a partial failure. */ unbindHostname(actor: PlatformActorId, hostname: string): Promise; /** * Ordered by hostname; the cursor is a hostname. * * `verticalSlug` narrows to the bindings of one vertical. It exists because the * alternative callers reached for was reading the WHOLE fleet's bindings and * filtering in JS — which makes an unrelated tenant's hostname row part of the * blast radius of a question about your own, and grows without bound as the fleet * does. A caller that wants one vertical's hostnames asks for them. */ listHostnames(actor: PlatformActorId, filter?: { tenantId?: TenantId; scopeId?: ScopeId; status?: HostnameStatus; verticalSlug?: string; } & ListPage): Promise; /** * Resolve a hostname for the router — the per-request read path. * * Takes NO actor and is not logged, for the same reason `resolveIdentity` does * not: this runs on every request, by a machine, before any staff member is * involved. K-24's access log records who *read the directory*, and a router * dispatching traffic is not that. * * Returns only `active` bindings. It does **not** re-check tenant or scope * suspension: `getScope` already fails closed there (§7), and a second * enforcement point is a second thing that can disagree. */ resolveHostname(hostname: string): Promise; /** * Persist a tenant. Idempotent on the id — re-creating an existing tenant is a * no-op, not an error (control-plane.md §4.1). `status` starts `active` and * `createdAt` is stamped host-side. This is what replaces "a tenant is a ULID * nobody used before" with a real record. */ createTenant(actor: PlatformActorId, input: CreateTenantInput): Promise; /** * Transition a tenant's status. `suspended` fails `getScope` closed for every * scope under the tenant (K-3's path) — the containment lever for non-payment * or an incident, reversible without deleting anything. `deleting` (§4.8) does * the same read-closed containment but marks the tenant for reap: entering it * stamps `deletingAt`, leaving it (an un-delete back to `active`) clears it, so * the grace-window sweep can age the tenant off that timestamp. `reaped` is * terminal and NOT reachable here — it is only ever reached via `reapTenant`. */ setTenantStatus(actor: PlatformActorId, tenantId: TenantId, status: TenantStatus): Promise; /** * Rename a tenant's DISPLAY name. Never the slug: registry ids * (`/`) and pinned workspaces are keyed on it, so the slug is * immutable here by omission — renaming display must not orphan a vertical. */ setTenantName(actor: PlatformActorId, tenantId: TenantId, name: string): Promise; /** * deleting → reaped. The terminal tenant reap (control-plane.md §4.8), the * tenant-level analogue of `reapScope`: clear the tenant's PII/config directory * rows (identities + identity pools, membership tuples, roles, entitlements, * orgs) and flip the `tenants` row to `reaped`, KEEPING that row as a tombstone * (burned slug + audit history) and `_substrat_admin_log` whole (the compliance * witness — never swept). Irreversible: the PII is gone, so `reaped` never * returns to `active`, and only a `deleting` tenant may be reaped (an illegal * source status fails closed). * * DIRECTORY-SIDE ONLY, deliberately: the tenant's scopes hold the domain bytes, * and wiping those runs ABOVE the kernel (a hosted scope's DO is CP-less, reached * via the vertical's `deleteScope`). The caller — the reap route and the * grace-window sweep — reaps every scope first (archive-if-needed → `reapScope`), * then calls this to clear the directory. Idempotent: re-running after a partial * failure converges (the DELETEs and the status flip are all set-to-empty). */ reapTenant(actor: PlatformActorId, tenantId: TenantId): Promise; /** * The tenant registry — the directory's inventory (control-plane.md §4.5 console * item 1). Ordered by tenant id (ULID = chronological); the cursor is a tenant id. */ listTenants(actor: PlatformActorId, page?: ListPage): Promise; getTenant(actor: PlatformActorId, tenantId: TenantId): Promise; /** The scope inventory. Ordered by scope_id (ULID = chronological); cursor = scope id. */ listScopes(actor: PlatformActorId, filter?: ScopeFilter): Promise; /** * The tenant-store ledger (#301): every platform-minted per-tenant store, optionally * narrowed by tenant and/or vertical. The deploy path reads `{ vertical }` to derive * the D1 bindings that must ride every serving-script upload (a re-deploy must never * drop a tenant's store binding); the console reads it as inventory. */ listTenantStores(actor: PlatformActorId, filter?: { tenantId?: TenantId; vertical?: string; }): Promise; /** * The blob-store ledger (#473) — the per-tenant-bucket twin of `listTenantStores`, * with the same two consumers: the deploy path derives the `r2_bucket` bindings that * must ride every serving-script upload, and the console reads it as inventory. */ listBlobStores(actor: PlatformActorId, filter?: { tenantId?: TenantId; vertical?: string; }): Promise; /** * One scope's directory record. Cross-checks the (tenantId, scopeId) pair and * returns undefined on a mismatch rather than another tenant's scope (K-3) — * the same fail-closed rule `ScopeHost.getScope` applies when minting a stub. * * Distinct from `ScopeHost.getScope`, which mints a capability stub for a * principal and grants no read of the record. This returns the record and * grants no execution. */ getScopeRecord(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId): Promise; /** Every table in the scope's database, with row counts; system tables flagged. */ listScopeTables(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId): Promise; /** * A bounded page of rows from one table of the scope's database. The table name is * validated against the live schema (an unknown one throws, never a blind query); * `limit` is clamped to the contract ceiling and `offset` pages. Rows are positional * arrays aligned to `columns`. */ readScopeTable(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, input: ReadScopeTableInput): Promise; /** * The events this scope has not yet shipped to Tier 2 (#1334), oldest first — * `drained_at IS NULL`, the column the spine has carried since the outbox * shipped and nothing has ever written. * * Read and mark are SEPARATE verbs, deliberately. Marking before shipping loses * events when the sink fails; shipping before marking can repeat them, and a * repeat is harmless — the lake is keyed by event id and every consumer in this * platform is already required-idempotent. At-least-once is the only one of the * two that cannot silently lose exact history, which is the point of the tier. */ readUndrainedEvents(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, limit?: number): Promise; /** * Stamp `drained_at` on events the sink accepted (#1334). Idempotent: marking a * row already marked changes nothing, so a retry after a partial ship is safe. * * Returns how many rows this call actually stamped — which is what makes the * idempotence observable, and what the `drainEvents` admin receipt is written * from. A pass that re-marks a batch it already shipped changes 0 and records * nothing, so the log never grows a row claiming an egress that never happened. */ markEventsDrained(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, eventIds: readonly string[]): Promise; /** * Clear `drained_at` on events stamped BEFORE `drainedBefore`, so the drain ships them * again (#1334). Returns how many rows it reopened. * * Why it exists: the outbox is the source of truth and the lake is derived from it, but * the stamp is one-way. Once a lake table is dropped — to change its schema, which a * Pipelines stream cannot do in place — every stamped row is history the drain will * never offer again, and the new table starts with a silent hole behind it. This is the * rebuild: nothing about the rows changes except that they are eligible to leave again. * It works only while the outbox still holds them, which is true while no outbox * pruning exists. * * `drainedBefore` is REQUIRED, and it is the whole safety property. Clearing every stamp * would also reopen rows shipped AFTER the table was rebuilt, putting them in the new * table twice. The caller names the instant the lost window ended — the moment the old * table stopped receiving — and only rows stamped before it are reopened. Strictly * before: a row stamped at exactly that instant went to the new table. * * A re-ship is a second egress of domain payloads, so it is audited on K-24's rule like * the stamp it undoes — in two halves. The INTENT row goes down before anything is * reopened, because the mutation and the row are separate writes and a failure between * them would otherwise leave a reopen no receipt could be written for: the retry finds * the stamps already clear, returns 0, and records nothing. The OUTCOME row follows only * when something moved, so a re-run over an exhausted window claims nothing. * * **Returns the count reopened by THIS call, which is bounded** (`REDRAIN_BATCH`), so the * protocol is a loop: call until it returns 0. The outbox is never pruned, so "every * stamped row before an instant" grows with the scope's whole lifetime, and on the * Durable-Object host that is one request against a fixed budget — unbounded, the scope * that most needs reopening is the one that could never finish, on every retry. A caller * that asks once reopens a prefix of the window, and must not report it as the whole. * * A cutoff in the FUTURE is refused here, at the boundary, not only by the control-plane * route: this is a public verb and in-process callers never pass that door. * * `countOnly` (#1545) answers the same question read-only: how many rows the window holds, * with nothing reopened and no receipt written — a count egresses nothing, so there is no * second egress for K-24 to record, and an intent row for a mutation that never happens is * a claim about the tenant's data that is not true. It is UNBOUNDED, unlike the reopen: * an aggregate materialises no rows, so it answers for the whole window in one call rather * than a batch of it. Absent, the verb reopens exactly as it always has. */ redrainEvents(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, input: { drainedBefore: string; countOnly?: boolean; }): Promise; /** * Facet a CO-LOCATED scope's outbox (#1239) — narrow, group, count. For a * dispatch vertical the route reads it through the vertical's own * `/internal/facets`; this is the co-located fallback, like `entityHistory`. * * The erased-payload rule lives in `facetEvents`, not here: a shredded event * yields the same NULL a missing field does, and folding the two would report * redacted history as "no value". The helper counts them apart. */ facetEvents(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, input: EventFacetInput): Promise; /** * One record's event history (#1235) on a CO-LOCATED scope — `readHistory`'s * answer, hoisted to the platform so a screen can render one entity's story: * payloads, the K-34 authorization chain, the K-42 impersonation stamp, the PII * class, the emitting operation and the version it ran as. For a dispatch * vertical the route reads it through the vertical's `/internal/history` * instead; this is the co-located fallback, exactly like `readScopeTable`. * * The nulls are facts, and the helper's contract keeps them distinct — an * erased payload, an unrecorded authorization, nobody impersonating. A caller * that flattens them to "missing" throws away the point of the read. */ entityHistory(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, input: EntityHistoryInput): Promise>; /** * One event's causal chain, walked backwards (#1237) — the answer to "this record * exists; what started that?" * * Returns the chain newest-first plus a `terminal` saying why it ends, and the five * reasons must not be flattened. `operation` is a COMPLETE chain (an operation * emitted the first event); `unrecorded` is a truncated one (something emitted it * before #1237 recorded causes) and reading it as complete would present a fragment * as the whole story. `depth` and `missing` are the two ways the walk gave up, and * `cycle` is the spine contradicting itself — an integrity failure, never a long chain. * * Same posture as `entityHistory`: this decodes payloads, so the caller's permission * check comes first, and the read is logged against the actor. */ eventCause(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, input: EventCauseInput): Promise; /** * One event expanded FORWARD (#1237) — what it set off: the consumers it reached, * and the events they emitted in turn. * * The mirror of `eventCause`, and the honest form of "expand this invocation": it is * a tree of recorded steps with real timestamps, not a timing waterfall. Nothing in * the platform emits a span for an operation, a permission check or an engine call, * so there is no duration for those steps and this does not invent one. * * Same posture as `entityHistory`: it decodes payloads, so the caller's permission * check comes first and the read is logged against the actor. */ eventEffects(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, input: EventEffectsInput): Promise; /** * Everything ONE call emitted (#1237), oldest first — `readInvocation`, hoisted. * * The third event read beside the two walks, and the one they cannot make: both follow * cause, so an operation's two independent events are invisible from each other. The * id is the one `invocationLog` wrote on the call's log line, which is what joins an * event to how long its call took and how it ended. * * Same posture as `eventEffects`: payloads are decoded, so the caller's permission * check comes first and the read is logged against the actor. */ invocationEvents(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, input: InvocationEventsInput): Promise; /** * Every delivery in the scope that gave up (#1525), newest event first — * `readDeadLetters`, hoisted. * * The scope-wide read the walks cannot make: they reach a delivery only through its * event, so a dead letter was visible only to someone who already knew which record to * open. Envelopes only, no payloads; logged against the actor like every read here. */ deadLetters(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, input: DeadLettersInput): Promise>; /** * One read-only SQL statement against the scope's database — the console the two * table-shaped reads deliberately weren't (#219). User SQL DOES reach the DB here, * so read-only-ness is enforced per statement instead of by construction: the * kernel's `assertReadOnlyQuery` textual gate (shared, so both adapters reject the * same statements) plus the adapter's authoritative backstop (better-sqlite3's * `prepare().readonly`; a rolled-back transaction on the DO). Results are capped at * SCOPE_QUERY_ROW_MAX rows (`truncated` set, never an error). Same actor + K-24 * access log (the statement itself is the logged argument) and the same K-3 * (tenantId, scopeId) cross-check, failing closed on a mismatch. Writes stay * impossible, not just forbidden — editing rows would forge the spine. */ queryScope(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, input: QueryScopeInput): Promise; /** * A COMPLETE dump of the scope's database — every table (the vertical's own AND the * `_substrat_*` spine), its DDL, and every row. This is the read side of the * preview/snapshot primitive (docs/architecture/preview-and-snapshots.md §3): the source a * fork copies into a new scope, or a governed `substrat scope pull` writes to a file. * * Unlike `readScopeTable` — bounded and blob-as-null, deliberately NOT a dump — this * exfiltrates the whole scope, so it is the more privileged read: same `PlatformActorId` * and K-24 access log, same (tenantId, scopeId) K-3 cross-check that fails closed on a * mismatch. It drops only SQLite's own `sqlite_*` internals (auto-managed, un-recreatable); * the spine is kept because a fork must carry the event/migration state to be faithful. */ exportScope(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId): Promise; /** * A bounded page of raw denial rows, newest first. Narrow with `actor` (who was * refused), `permission` (which key), `operation`, and a `since`/`until` window. */ listDenials(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, filter?: DenialFilter): Promise; /** * The same log bucketed per (actor, permission) — K-35's "first occurrence + count * per actor/key/window" — busiest first, with the filtered totals and the unfiltered * window facts beside them. This is the view an operator opens first: "who has been * probing for access they don't hold" is a question about counts, not about rows. * `groupBy: 'operation'` asks the other question (#1456) — "which operation keeps * getting refused" — and the answer says which grouping it carries. */ summarizeDenials(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, filter?: DenialFilter): Promise; /** * A COMPLETE dump of the directory itself: tenants, scopes, hostnames, verticals, * entitlements, identities, and the audit spine. The platform-level analogue of * `exportScope`, and audited the same way (K-24 access log) — it exfiltrates every * tenant at once, so it is the single most privileged read a host offers. * * Deliberately NOT a substitute for per-scope PITR, which is better at what it does * (~30 days, continuous, per scope). This answers the different question: the * directory is one Durable Object, and if it is deleted or corrupted outright there * is nothing to point PITR at. See `restoreDirectory` for the write half. */ exportDirectory(actor: PlatformActorId): Promise; /** * Replace the directory with a dump — break-glass, and the only write in `HostAdmin` * whose blast radius is every tenant at once. * * It is a REPLACE, not a merge: the dump's contents become the directory, and * anything created since the copy was taken is gone. That is the honest semantic for * a recovery (a merge would silently interleave two histories of the same tenant), * and it is why the route in front of this refuses a directory that still has * tenants unless the caller explicitly says to overwrite. * * Audited as `restoreDirectory` in the admin log that the restore itself just * replaced — so the first entry after a restored history is the restore. */ restoreDirectory(actor: PlatformActorId, dump: DirectoryDump): Promise; /** * provisioning → active. The vertical's confirmation that a scope exists (K-31). * * `provisionScope` writes the directory row as `provisioning`, and nothing may use * it until this runs — `getScope` fails closed on any non-active scope, so a row * whose vertical never provisioned is inert rather than misleading. * * Deliberately a separate call rather than a flag on `provisionScope`: the two * happen against DIFFERENT systems, and the gap between them is a real state that * something has to be able to observe and retry. */ activateScope(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId): Promise; /** active → suspended. Reversible containment (incident, dispute). */ suspendScope(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId): Promise; /** suspended → active. */ unsuspendScope(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId): Promise; /** active|suspended → archived. Stops the active-scope meter (§9). */ archiveScope(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId): Promise; /** * archived → active. A RESTORE, never a flag flip (control-plane.md §4.2): * §9's meter can only charge on "active scope" if un-archiving is a deliberate, * audited act. Jurisdiction is untouched — it is fixed at provisioning (K-7). */ unarchiveScope(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId): Promise; /** * archived → reaped. The terminal reap (control-plane.md §4.4): wipe the scope DO's * storage — Cloudflare never garbage-collects a Durable Object, so an archived app's * bytes persist forever until this runs — while KEEPING the directory row as a * tombstone (audit history + burned slug, §4.4). Unlike `unarchiveScope` this is * IRREVERSIBLE: the bytes are gone, so `reaped` never returns to `active`, and only * an `archived` scope may be reaped (an illegal source status fails closed). Unlike * `deleteSnapshot` it reaps a PRIMARY scope, not a fork, and does not delete the row. * * The CP-less byte-wipe (a hosted scope's DO lives in the vertical's own deployment) * is orchestrated by the caller via the vertical's `deleteScope` before this; the * adapter half wipes any co-located storage and flips the status. * * Refuses (fail closed) while the scope still holds a bound hostname — a serving app * always does, so the wipe cannot land on one that is still online; unbind it first. * `force` is the deliberate-teardown bypass (tenant reap §4.8, retention sweeps §4.4), * where releasing every name is the point; interactive per-scope reap never sets it. * * `backupRef` names the recoverable copy the caller stored before calling (#493) and is * carried into the admin-log entry. The reap itself neither takes nor verifies the * backup: taking it needs the scope's BYTES, which for a hosted scope live in the * vertical's own deployment and are only reachable above this seam — the same reason * the byte-wipe is orchestrated by the caller. What this parameter buys is that the * audit trail answers "was there a copy, and where" from the reap entry itself, instead * of an operator correlating two timestamps. Absent ⇒ no copy was taken. */ reapScope(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, opts?: { force?: boolean; backupRef?: string; }): Promise; /** * Seal payloads for the subjects that own them, on the way OUT to a platform-retained * copy. Batched — one call per dump, not one per row — and positional: result `i` * corresponds to `items[i]`. * * A `null` result means REFUSED, not failed: the subject is tombstoned by a prior shred, * and minting a fresh key for them would resurrect readability the erasure was supposed * to end. The caller writes `null` into the copy, which is the same shape a live * redaction leaves — so a restored backup and a live scope agree about what is gone. * * Keys are minted on first use, so a subject who has never been exported has no key and * costs nothing. Access-logged (K-24) like every directory read that touches subject data. */ sealSubjectPayloads(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, items: readonly { subjectId: string; plaintext: string; }[]): Promise<(SealedSecret | null)[]>; /** * The inverse, on the way back IN from a platform-retained copy. Positional like `seal`. * * `null` means the key is gone — the subject was shredded between the copy being taken * and this restore — and the caller restores a null payload. This is where the erasure * actually bites: the bytes were always there in the backup, and after the shred nothing * can turn them back into a person. */ openSubjectPayloads(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, items: readonly { subjectId: string; sealed: SealedSecret; }[]): Promise<(string | null)[]>; /** * Erase one data subject from a scope: redact the spine payloads keyed to them, then * destroy their key and tombstone the id. * * **That order is load-bearing.** Both halves are idempotent and a crash between them * self-heals on retry, so the tiebreak is which half-done state harms the person: a run * that died after redacting leaves ciphertext in a backup nobody can read without the * key; one that died after destroying the key first would leave their PII sitting in the * live operational database while the audit log claims they were erased. Redact what is * reachable, then destroy what makes the unreachable unreadable. * * Audited as `shredSubject` with the receipt as `after`, and access-logged: this both * mutates and destroys evidence, so it is the rare action that belongs in both logs. * * What it does NOT reach is written down rather than implied — vertical-owned PII in a * vertical's own table, copies already handed to a customer, and the PITR window (see * kernel-design.md's answer to open question 17). A mechanism whose limits are * undocumented gets oversold by someone who was not in the room. */ shredSubject(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, subjectId: string): Promise; /** * Open a session that acts as `input.principal`, and record it. * * The admin-log row is written BEFORE the session is returned, so the audit * entry precedes every row the session goes on to touch — K-33's failure * ordering, chosen there for the same reason: a partial state has to be * visible, and "the session was opened" is the state that matters here. * * Bounded and reason-carrying by construction: `reason` is required, * `minutes` is capped at `IMPERSONATION_MAX_MINUTES` (an over-ask is refused * rather than clamped), and the mode is `read-only` unless the caller said * otherwise. Fails closed on a tenant/scope that does not exist or is not * active — the same gate `getScope` applies, applied where the session is * minted rather than only where it is used. */ beginImpersonation(actor: PlatformActorId, input: BeginImpersonationInput): Promise; /** * Close a session before its clock runs out. Idempotent on an already-closed * one (it returns the session as it stands), because "stop that session" must * not fail because somebody else already did. Never deletes the row: a session * that once existed is why some rows carry the stamp they do (K-21). */ endImpersonation(actor: PlatformActorId, session: ImpersonationSessionId): Promise; /** * Read the session log — who acted as whom, when, why, and whether it is still * open. A staff READ, so it is access-logged like every other one (K-24). */ listImpersonations(actor: PlatformActorId, filter?: ImpersonationFilter): Promise; /** * Turn a SKU flag on for a tenant, optionally carrying plan fields. Idempotent * and audited only when something changed; a re-grant with different fields is * an UPDATE (renewal, tier change) audited with before/after. Omitted plan * fields preserve the row's current values — explicit null clears (see * `entitlementGrantInput`). */ grantEntitlement(actor: PlatformActorId, tenantId: TenantId, entitlementKey: string, plan?: EntitlementGrantInput): Promise; /** Turn it off. A tenant's scopes lose access to that module's operations. */ revokeEntitlement(actor: PlatformActorId, tenantId: TenantId, entitlementKey: string): Promise; /** * The tenant's grants with their plan fields (control-plane.md §5 meter 2). * Includes expired rows — gate-dead but visible, so a lapsed trial can be * renewed rather than looking never-granted. */ listEntitlements(actor: PlatformActorId, tenantId: TenantId): Promise; /** * Meters 1 and 2 as one reading (#38; control-plane.md §5) — tenants and active * scopes, plus the entitlement store grouped by SKU and tier. Fleet-wide, or * narrowed to one tenant with `{ tenantId }`. * * An AGGREGATE, not a list, and that is the whole point: both numbers are already * derivable by walking `listScopes` + `listEntitlements` per tenant, but doing it * that way is N+1 round trips against the directory and — worse — re-derives the * billable rule in every caller. Two rules live here instead, once (see * `meterReading`): a scope is billable only if its TENANT is active too (a cascade * suspension is an outage, not revenue), and expiry is evaluated at the reading's * instant so a lapsed grant reads as lapsed rather than as never-granted. * * Computes nothing that needs a data pipeline. Meters 3 and 4 are absent because * they are uncomputable by construction, not because this is a first slice — the * per-scope outbox has no cross-tenant fan-in, and reads emit nothing at all. * * Nothing is stored: a reading is recomputed per call. D-30 is meter, do not bill, * and a persisted running total is the first half of a billing ledger. */ readMeters(actor: PlatformActorId, filter?: { tenantId?: TenantId; }): Promise; /** * Bind an external identity to a principal + home node. Audited. * * Keyed `(tenantId, provider, externalId)` — **not** `(provider, externalId)`. * kernel-design §4.3: with one auth pool per white-label tenant, an external subject * id is unique only *within* its pool, so a globally-keyed mapping is a cross-tenant * identity bleed. It is also what lets one staff login belong to several tenants: one * external id, one row per tenant. * * Idempotent when the key already maps to the SAME principal. A key already bound to a * DIFFERENT principal **throws** — it means two subjects collided, and silently * ignoring it would resolve the second person as the first. */ /** * Whether this host was built with a `SecretBox` — i.e. whether it can store a * credential at all (#603). * * A synchronous property, not a probe: the answer is fixed when the host is * constructed and needs no round trip. It exists so a caller can refuse BEFORE * doing work that only makes sense if the result can be stored — the connection * relay asks the provider to verify a candidate credential, and asking on a * host that could never keep the answer spends a real outbound call, and hands * the plaintext to the provider, to reach the same failure one step later. * * `false` does not disable the writes below; they still fail closed with a * `SecretBoxUnconfiguredError`. This is the readable form of the same fact, so * a transport can answer "this deployment cannot do that" (503) instead of * letting a boot-time misconfiguration surface as an unexplained 500. */ readonly canStoreSecrets: boolean; /** * Store a tenant's authorization for one provider, held by one vertical. * * The credential is sealed by the host's `SecretBox` before it touches the * directory, and the admin-log row carries **metadata only** — provider, * label, scopes. That is structural, not careful: `_substrat_admin_log` is * append-only, so a secret written into it could never be removed. * * Takes a `PlatformActorId`, but connecting a provider is really a tenant * admin's act — §3.5 settled that the authority ORIGINATES in-scope (option B) * and the effecting caller here is host code holding platform authority * legitimately (an OAuth callback, the connection relay). Attribution rides in * `input.createdBy` — the authorizing principal, recorded on the connection * and in the audit row — never laundered into the actor (§3.5.1). */ createConnection(actor: PlatformActorId, input: CreateConnectionInput): Promise; /** * This connection's PUBLIC sealing key (#687) — what the platform gathers and * projects into a scope so module code can `ctx.sealToConnection` to it. * * **Mints on first ask, idempotently.** Not a separate "create key" verb, * because the alternative is a fleet where connections made before this * existed can never receive a sealed value: a live Scrive credential in the * fleet is years of real contracts old, and re-connecting it to acquire a keypair is * not a migration anyone should have to run. Asking is the back-fill. * * The private half is sealed under the host's `SecretBox` and stored beside * the credential, in a **keyId-indexed** set from day one — even holding * exactly one member (D-4: widening a single-key column into a set later is a * migration against live connections; starting with the map is free). * * Unaudited, like `resolveIdentity`: it is a machine read on a delivery path * that returns a public key, and an audit row per provision would say nothing * a reader could act on. Every USE of the private half is already attributable * — it happens inside a connector dispatch, against a named connection. */ connectionSealingKey(id: ConnectionId): Promise; /** * Every live connection's public sealing key for one (tenant, vertical) — the * gather the platform projects with provision and reconcile. * * Minting is idempotent per connection, so this doubles as the back-fill for a * tenant whose connections all predate the keypair. */ connectionSealingKeys(tenantId: TenantId, vertical: string): Promise; /** Metadata only — never the credential, at any privilege level. */ listConnections(actor: PlatformActorId, filter?: ConnectionFilter): Promise; /** * Replace the sealed credential — the OAuth refresh path, and the connection * relay's rotation (connections.md §3.5.2). `opts.rotatedBy` names the tenant * principal whose permission-checked act authorized the rotation, recorded in * the audit metadata — the rotate-side analogue of `createdBy` on create * (§3.5.1). Omitted ⇒ the effecting actor stands alone, the platform-driven * refresh path. */ updateConnectionSecret(actor: PlatformActorId, id: ConnectionId, secret: ConnectionSecret, expiresAt?: string, opts?: { rotatedBy?: string; }): Promise; /** * Withdraw a connection. Tombstones like K-21 rather than deleting: a * credential that once had access is evidence of why an access was allowed. * Terminal — a replacement is a new connection, which is why the uniqueness * constraint ignores revoked rows. */ revokeConnection(actor: PlatformActorId, id: ConnectionId): Promise; /** * Open the credential for one (tenant, vertical, provider[, account]) — the * connector's read, and the only path in the system that yields plaintext. * * **Takes no actor and is not audited**, the same exemption `resolveHostname` * and `resolveIdentity` hold and for the same reason: it is a machine read on * the request path, and an audit row per outbound HTTP call would drown the * log that matters. What *is* recorded is health — `recordConnectionUse` below * — which is the signal an operator can actually act on. * * A provider that supports several external accounts per tenant (GitHub's * namespaces) may hold several live connections; `externalAccountRef` selects * among them. Omitted, the single live connection is returned — and when more * than one is live the read **throws** rather than picking one arbitrarily, * because acting against the wrong tenant account is worse than failing. */ openConnection(tenantId: TenantId, vertical: string, provider: string, externalAccountRef?: string): Promise; /** * Record that a connection worked, or did not (§3.7). Written by the connector * runtime; read by a console. Not audited — it is telemetry about a machine * read, not a control-plane mutation. */ recordConnectionUse(id: ConnectionId, outcome: { ok: true; } | { ok: false; error: string; }): Promise; /** * Durable, connection-scoped state a connector keeps for itself — the home a * connector's bookkeeping never had. * * The load-bearing use is **dispatch idempotency**. A connector runs from the * outbox at-least-once, so a redelivery must not repeat an outward effect — * and it cannot record "already did this" in the scope, because a connector * runs *inside* the scope's dispatch and re-entering the scope actor * deadlocks. This lives in the DIRECTORY instead, which the connector reaches * through `ctx.admin` without touching the scope: before it creates a document * at the provider it checks for prior state under a deterministic key, and * skips if it is there. * * `value` is arbitrary JSON, opaque to the kernel — a `{ documentId, … }` map * the connector interprets. NOT audited: this is high-frequency machine state, * one write per dispatch, the same class as `recordConnectionUse`. Rows die * with the connection (revoke cascades). */ putConnectorState(id: ConnectionId, key: string, value: unknown): Promise; getConnectorState(id: ConnectionId, key: string): Promise; /** * Every state row for a connection, optionally narrowed to keys under a * `prefix`, ordered by key. * * `getConnectorState` answers "did I already do THIS one" from a deterministic * key — the dispatch path. This answers "what is still outstanding" without * knowing the keys up front, which is what a **poll driver** needs: a connector * records one row per dispatch under `:dispatch:`, and a * scheduled sweep enumerates them (`prefix = ':dispatch:'`) to * reconcile each against the provider. Without it a sweep would have to be told * every id it might reconcile — which defeats the point of a sweep. * * A read of directory-local machine state, like get/put; not audited. */ listConnectorState(id: ConnectionId, prefix?: string): Promise<{ key: string; value: unknown; }[]>; linkIdentity(actor: PlatformActorId, input: IdentityLink): Promise; /** * Remove a principal's identity link(s) in a tenant — the inverse of `linkIdentity`, * keyed by principal (not external id) so a caller who removed a member can sever * their login from the team without knowing their external subject. After this, * `listIdentityTenants` no longer returns the tenant for that person and * `resolveIdentity` no longer resolves — so the team disappears from their switcher. * A DELETE, not a tombstone: the identity map is current operational state (the audit * is the admin log), and re-inviting must be able to re-link a fresh principal. * Idempotent: unlinking a principal with no link is a silent no-op. */ unlinkIdentity(actor: PlatformActorId, tenantId: TenantId, principal: PrincipalId): Promise; /** * Register an identity pool and its topology (K-23). A provider must be registered * before it may link: an unregistered pool has not said whether the same * `externalId` in two tenants is one human or two, and the kernel will not guess. * Idempotent on an identical registration; a conflicting re-registration throws, * since changing a live pool's topology silently reinterprets every row it owns. */ registerIdentityPool(actor: PlatformActorId, pool: IdentityPool): Promise; getIdentityPool(actor: PlatformActorId, provider: string): Promise; /** * Which tenants this login exists in — the cross-tenant question, kept distinct * from resolution because they have different safety conditions. * * **Central pools only.** On a tenant-bound pool the same `externalId` in another * tenant is a different person, so enumerating would hand one person another's * tenant list; this throws there rather than returning the single obvious answer, * because asking at all is a category error the caller should see. */ listIdentityTenants(actor: PlatformActorId, provider: string, externalId: string): Promise; /** * `listIdentityTenants`, with what a caller does next already attached: each tenant * row (status included), the login's principal in it, and the scope the link was * made in. One read and ONE access-log row, where composing the same answer out of * `listIdentityTenants` + a `getTenant`/`resolveIdentity` per tenant is 2N+ reads and * as many log rows — on a hosted directory each of those is a round trip, and a * request that resolves its caller this way pays them before it does any work. * * Same safety condition as `listIdentityTenants`, for the same reason: **central * pools only**, and it throws on a tenant-bound or unregistered pool. Ordered by * tenant id. Non-active tenants are returned, not filtered — see `IdentityMembership`. */ listIdentityMemberships(actor: PlatformActorId, provider: string, externalId: string): Promise; /** * Every identity link in one tenant — the projection read (#406). This is what the * platform gathers (authoritatively, never from a caller's body) to deliver a * tenant's links WITH provisioning/reconcile, the same trust line entitlements ride * (#310), so a CP-less vertical resolves `(provider, externalId) → principal` from * its own storage at request time. A staff read of the directory, so it is * access-logged (K-24) — unlike `resolveIdentity`, which is the per-request machine * path and records nothing. */ listIdentityLinks(actor: PlatformActorId, tenantId: TenantId): Promise; /** * Resolve an external identity within a tenant — the auth adapter's read path. * * The tenant is an INPUT: the caller knows which pool the credential came from (its * hostname, or the org claim on a pool-scoped token). It is not derived from the * identity, because across per-tenant pools the same `externalId` legitimately names * different people. */ resolveIdentity(tenantId: TenantId, provider: string, externalId: string): Promise; /** * The append-only admin audit trail, oldest first by default (ULID order is * chronological). Read path for the console history and the permission-diff * human checkpoint (control-plane.md §4.5) — where the interesting column is * `before`/`after`: a redefined role captures its old and new shape there, and * that diff IS the checkpoint. */ auditLog(actor: PlatformActorId, filter?: AuditLogFilter): Promise; /** * Record one operational failure (#559) — a deploy, restore, or provision that the * PLATFORM could not complete. Deliberately not `recordAdmin`: the audit spine * answers "who changed what", and a failure changed nothing. The row gives the * upstream trace reference (Cloudflare's `internal error; reference = `) a * durable, queryable home; before this its only record was a vertical script's * short-retention observability logs. * * MUST NOT throw in normal operation: the adapters bound the row's size and prune * old rows on write, and the transport calls this from failure paths — a recorder * that fails must never mask the failure it was recording (callers still guard). */ recordOpsFailure(entry: OpsFailureInput): Promise; /** * One sweep unit's outcome (#1232). Telemetry about a machine pass — not * audited, on `recordConnectionUse`'s precedent — and callers fire-and-forget: * a recorder that throws must never sink the pass it is recording. */ recordSweepRun(entry: SweepRunInput): Promise; /** * The recorded operational failures, newest first by default — the console's * failures view and the "what does this `reference = ` belong to" lookup. * Retention-bounded (unlike the never-swept admin log), so an empty read means * "nothing recent", never "nothing ever". */ listOpsFailures(actor: PlatformActorId, filter?: OpsFailureFilter): Promise; /** The sweep record, newest-first by default. Reads are access-logged (K-24). */ listSweepRuns(actor: PlatformActorId, filter?: SweepRunFilter): Promise; /** * The fingerprint-grouped failure classes (#1233), most recently seen first. * Reads are access-logged (K-24). No cursor by design: grouping IS the * compression — cardinality is the number of distinct failure shapes, not the * number of failures — and `limit` bounds the read. */ listIssues(actor: PlatformActorId, filter?: IssueFilter): Promise; /** * A staff verdict on one issue (#1233): resolve, ignore, or reopen. `regressed` * is ingest's word and not accepted here. Returns the updated row, or undefined * for an unknown fingerprint. Audited with the before/after status diff (K-33). */ setIssueStatus(actor: PlatformActorId, fingerprint: string, status: IssueStatusInput): Promise; /** * Meter 3's ledger (#1054): one line per model call a vertical made through the * platform's model host, drained here as a `model-usage` intent. Idempotent on the * intent id — a retried drain records nothing twice — and retention-bounded * (MODEL_USAGE_RETENTION_DAYS), pruned on write like the ops-failure record. * Returns whether THIS call wrote the row, so a drain can settle honestly. */ recordModelUsage(input: ModelUsageInput): Promise<{ recorded: boolean; }>; /** The lines, newest first by default — the per-tenant usage read behind the console. */ listModelUsage(actor: PlatformActorId, filter?: ModelUsageFilter): Promise; /** * Meter 3, folded: per (tenant, vertical, model) over a half-open window, list price * summed exactly and the platform's margin applied at read time (`foldModelUsage`). * Nothing is stored by the read — D-30's "meter, don't bill" holds; the invoice is * still somebody else's. */ summarizeModelUsage(actor: PlatformActorId, window: ModelUsageWindow, marginPercent: number): Promise; /** * The staff access log (K-24) — who READ the directory, when, and how much came * back. Reading it is itself recorded: who examined the record of who looked is * the question an incident asks second. */ accessLog(actor: PlatformActorId, filter?: AccessLogFilter): Promise; /** * Record a scope read the control plane DELEGATED to the vertical holding the data * (#1357) — the K-24 row the co-located branch would have produced. * * Every scope-addressed read runs the same ladder: resolve the scope record, then ask * the vertical when one resolves and the co-located host otherwise. Only the second * branch reaches `HostAdmin`, where `recordAccess` lives — so on the production path, * which is every hosted vertical, the access log holds a `getScopeRecord` entry and * nothing saying WHAT was read. An auditor could not tell a summary page from a table * walk from one record's history, and the history page is the one carrying payloads, * a data subject and an authorization chain. * * **This is a new power and is shaped to be a narrow one.** Nothing outside an adapter * could add an access row before. So the method comes from a closed set * (`delegatedReadMethod`), the actor comes from the request context the same way every * other verb's does, and the record carries no `id` and no `at` — the adapter stamps * both, so a caller can neither backdate a row nor collide one. * * **It throws if the row cannot be written, and the caller must not swallow that.** * That is not a new trade: on the co-located branch `recordAccess` is awaited inside * the read, so a failed write already fails the request there. Matching it keeps the * two branches indistinguishable in the log, which is the entire objective — and the * alternative, returning rows whose disclosure went unrecorded, is the thing K-24 * exists to prevent. */ recordDelegatedRead(actor: PlatformActorId, record: DelegatedReadRecord): Promise; /** * Stamp `drainedAt` on every not-yet-drained access row up to and including * `upToId`, marking them shipped to Tier 2. Returns how many rows moved. * * **Called only AFTER the sink confirms the write.** The stamp is what licenses * `pruneAccessLog` to delete a row, so stamping first and shipping second would * turn one failed upload into permanently deleted evidence. Ship, confirm, stamp, * prune — in that order (`sweepAccessLog`). * * `upToId` rather than a list of ids because the id is a ULID and the log is * append-only: "everything up to here" is exactly the batch that was read, and * rows written during the shipment sort strictly after it. The `drainedAt IS NULL` * guard makes a re-run idempotent — a retried pass re-stamps nothing and returns 0. */ markAccessLogDrained(actor: PlatformActorId, upToId: string, drainedAt: string): Promise; /** * Prune access-log rows already shipped to Tier 2, oldest first, up to `limit`. * * **Only drained rows.** Pruning on age alone would destroy evidence while calling * itself a retention policy — the failure K-21 rejected for tuples, one layer up. * A deployment that configures no sink drains nothing, so this prunes nothing and * the window stays unbounded — still a stated limitation rather than a policy, but * now one the operator opts out of rather than one the platform imposes. */ pruneAccessLog(actor: PlatformActorId, limit: number): Promise; } export interface ProvisionScopeInput { tenantId: TenantId; scopeId: ScopeId; /** * Unique within the tenant; the console's human handle for the scope, shown as * `{tenant.slug}/{scope.slug}`. Optional and defaulted to the lowercased * scopeId — a ULID lowercases into a valid slug, so the default is structurally * valid and unique by construction. A caller that means something by the name * supplies one; the default is a placeholder, not a convention. */ slug?: string; /** Vertical vocabulary ('brf', 'filial'). The kernel never branches on it. Defaults to 'scope'. */ kind?: string; /** Display name. Defaults to the slug. */ name?: string; /** Which vertical's deployment executes this scope. Defaults to null. */ vertical?: string | null; storageShape?: StorageShape; jurisdiction?: Jurisdiction; /** * Fork provenance (preview-and-snapshots.md §3): the scope this one was copied * FROM, and WHEN. `importScope` sets both from the dump; a normal provision leaves * them null. Recorded on the directory row — the kernel never branches on them. */ forkedFrom?: ScopeId; forkedAt?: string; /** * Retention horizon (preview-and-snapshots.md §3): when the GC sweep may reap this * scope. Only meaningful on forks — the reaper refuses non-forks regardless. Unset = * retained until deliberately deleted. */ expiresAt?: string; } /** What `provisionTenantStore` needs to mint (or idempotently re-resolve) a per-tenant * relational store (#301). Keyed by (tenant, vertical, binding) — the same tenant can hold * one store per declared `tenantStoreNeed.binding`, and two verticals never share one. */ export interface TenantStoreProvisionInput { tenantId: TenantId; /** The vertical the store belongs to — its `tenantStoreNeed` binding is scoped to it. */ vertical: string; /** The declared `tenantStoreNeed.binding` this store satisfies (SCREAMING_SNAKE). */ binding: string; } /** * A live per-tenant relational store the vertical reached through the host (#301) — the * thing `openTenantStore` hands back. Deliberately the same `query`/`exec` VOCABULARY as * `ScopedSql`, so a vertical's own-store code reads like its scope-DB code — but **async**, * because the store is only reachable asynchronously on Cloudflare (a `D1Database` binding * in the worker, the D1 HTTP API from the control plane), and a contract only the SQLite * adapter could satisfy would be no contract at all. Plus a `native` escape hatch for a * library (e.g. Better Auth) that wants the raw driver. * * `native` is `unknown` at the contract on purpose: a `better-sqlite3` `Database` on the * pure adapter, a `D1Database` on Cloudflare (in the worker — the control plane's HTTP-query * store has no in-process driver and carries `null`). The vertical narrows it in its own * runtime-specific harness — exactly the node/worker split a hosted vertical already has — * which is what lets one vertical run unchanged against D1 in prod and a `.sqlite` file locally. */ export interface TenantRelationalStore { query>(sql: string, params?: readonly SqlValue[]): Promise; exec(sql: string, params?: readonly SqlValue[]): Promise<{ changes: number; }>; /** The underlying driver, for a library that needs it. Adapter-typed; `unknown` here. */ readonly native: unknown; } /** * One row of the tenant-store ledger (#301): the platform-minted per-tenant store * satisfying a vertical's declared `tenantStoreNeed`, keyed (tenant, vertical, binding). * The ledger is what makes provisioning idempotent, tells the deploy path which D1 * bindings must ride every serving-script upload (a re-deploy must never drop a tenant's * store), and tells a future reap what to tear down. */ export interface TenantStoreRecord { tenantId: TenantId; vertical: string; binding: string; kind: 'relational'; ref: string; createdAt: string; } /** What `provisionBlobStore` needs to mint (or idempotently re-resolve) a per-tenant * blob store (#473). Keyed by (tenant, vertical, binding), exactly like tenant stores. */ export interface BlobStoreProvisionInput { tenantId: TenantId; /** The vertical the store belongs to — its `blobStoreNeed` binding is scoped to it. */ vertical: string; /** The declared `blobStoreNeed.binding` this store satisfies (SCREAMING_SNAKE). */ binding: string; } /** * A live per-tenant blob store (#473) — the byte side of the attachment surface. The * contract is async and byte-shaped (Uint8Array + web-standard types only) because the * store is only reachable asynchronously on Cloudflare (an `R2Bucket` binding); the pure * adapter backs it with a per-tenant directory and resolves immediately. * * Keys are PLATFORM-DERIVED (`attachmentBlobKey`), never caller-supplied strings — the * per-scope prefix inside a per-tenant store is constructed in kernel/adapter code, which * is what turns "scope// is a convention" into "cross-scope keys are unwritable". */ export interface TenantBlobStore { put(key: string, body: Uint8Array, opts?: { contentType?: string; }): Promise; get(key: string): Promise<{ body: Uint8Array; contentType?: string; } | null>; delete(key: string): Promise; /** Keys under `prefix` — the GC/ops walk. */ list(prefix: string): Promise; } /** * One row of the blob-store ledger (#473) — same idempotency/deploy/reap roles as * {@link TenantStoreRecord}: a retried provision re-resolves the same `ref`, the deploy * path derives which `r2_bucket` bindings must ride every serving-script upload, and a * tenant reap knows what to tear down. */ export interface BlobStoreRecord { tenantId: TenantId; vertical: string; binding: string; kind: 'blob'; ref: string; createdAt: string; } /** * The blob key an attachment's bytes live under inside the per-tenant store (#473). * Scope-prefixed by construction: every key this platform ever writes for a scope sits * under `scope//`, and the attachment id (a fresh ULID per upload) makes keys * write-once — the two properties the attachment integrity story rests on. Exported so * both adapters (and an ops GC walk) derive the same key; module and route code never do. */ export declare function attachmentBlobKey(scopeId: string, attachmentId: string): string; /** * The §4.3 entitlement-gate denial, worded identically wherever the gate lives — the * coordinator against the shared CP, a scope DO against its projection, the SQLite * adapter against its directory. * * It names BOTH sides (#691). The required key alone reads as "buy the SKU", which sent * the 2026-08-15 production lockout down the wrong path for half a day: the tenant held four * keys, just under a workspace-prefixed name the manifest could never match. Required-vs-held * IS the diagnosis, so the message that reports the denial should carry it — a key that is * *nearly* right (prefixed, misspelled, expired) is invisible until you can see both lists. * * `held` is every key projected for the tenant, expired ones included and marked: a lapsed * grant denies exactly like an absent one, and "you have it, it ran out" is a different fix * from "you never had it". * * **Its code is `not_found`** at all three throw sites (#113). Not an oversight and not * `forbidden`: the taxonomy's `not_found` row already covers "exists, and must read as * absent" for K-3's cross-tenant case, and every vertical had independently arrived at a * 404 here for the same reason — *"a 403 would confirm the feature exists"* * ([`todo/routes.ts`](../../../demos/todo/src/routes.ts)). Naming the code once is what * retires those hand-written patterns. */ export declare function entitlementDenial(operation: string, requiredKey: string, held: readonly { key: string; expired: boolean; }[]): string; /** * Read a hostname row's stored `validation_records` — the DNS records Cloudflare * returned while a custom hostname was being issued. * * Tolerant on purpose, and the tolerance is the point. This column is the only part * of a hostname row that is not written by this platform: it is whatever the issuance * API handed back, stored verbatim. A bare `JSON.parse` here made one unparseable blob * anywhere in the fleet into a `SyntaxError` — which is not a `ZodError`, so the * control-plane's error mapper did not recognise it and answered a blank 500. That took * out every `listHostnames` that crossed the bad row, including the one on the deploy * path, so a cert-validation detail for one domain could stop unrelated verticals from * shipping. * * Cert-validation records are display data — the console renders them so an operator can * copy a CNAME. Nothing routes on them. So an unreadable blob degrades to "no records to * show" for that one hostname, and every other row in the page still maps. Both adapters * call this so neither can be the lenient one. */ export declare function parseValidationRecords(raw: string | null | undefined): DnsRecord[]; /** Input to `ScopeAttachments.upload` (#473). Bytes ride here — NOT through * `ScopeStub.invoke`, whose structured-clone pipe and per-scope serialization are * exactly the wrong path for megabytes of JPEG (the issue's point). */ export interface AttachmentUploadInput { /** The owning entity — must be a declared `attachmentTargets` entityType. */ entity: EntityRef; filename: string; contentType: string; visibility: Visibility; body: Uint8Array; } /** An opened attachment: the metadata fact plus the bytes it witnesses. */ export interface OpenedAttachment { record: AttachmentRecord; body: Uint8Array; contentType: string; } /** * The attachment surface a host mints per (principal, scope) — `attachmentTargets` * finally consumed (#473). Every method is gated INSIDE the platform by the declared * target's permission, checked as the ambient principal with the owning entity as the * per-entity ref (so entity-narrowed grants resolve): `readPermission` for list/open, * `writePermission` (default: the read key) for upload/remove. The metadata fact lands in * `_substrat_attachments` inside the scope's own database — under scope serialization, * with an `attachment.added`/`attachment.removed` spine event in the same transaction — * while bytes go straight to the per-tenant blob store, never through the scope pipe. */ export interface ScopeAttachments { upload(input: AttachmentUploadInput): Promise; /** Records for one entity, newest first. Gated by the target's readPermission. */ list(entity: EntityRef): Promise; /** Record + bytes, or null for an id this scope does not know. Gated per entity. */ open(attachmentId: string): Promise; /** Delete row (and event) first, then bytes; returns the removed record, null if unknown. */ remove(attachmentId: string): Promise; } /** * Narrow `listRoles` (control-plane.md §4.5 console item 4 — the permission * diff's runtime half). */ export interface RoleFilter extends ListPage { tenantId?: TenantId; /** * A module id, or 'vertical'. Both mean "declared in code" — see * `roleDefinition.source`. Filtering for operator-created roles is not * possible until something can create one. */ source?: string; } /** Narrow `listScopes` (control-plane.md §4.5 console items 1 and 6). */ export interface ScopeFilter extends ListPage { tenantId?: TenantId; /** One status or any of several — the console's All / Suspended / Archived tabs. */ status?: ScopeStatus | ScopeStatus[]; vertical?: string; } /** * Narrow the admin audit trail (control-plane.md §4.4/§4.5). Every field is a * conjunctive AND; omitting all of them reads the whole log, which is why `limit` * exists — the table is append-only and only grows. */ export interface AccessLogFilter extends ListPage { actor?: PlatformActorId; tenantId?: TenantId; method?: string; /** * Narrow by drain state: `false` selects rows not yet shipped to Tier 2, `true` * those already shipped. Omitted reads both. * * This exists for the drain itself — "the oldest rows that have not left yet" is * the only query it needs, and expressing it here keeps the sweep on the audited * `accessLog` seam rather than giving it a private read path into the table. */ drained?: boolean; } /** * How long a recorded operational failure is kept (#559). Telemetry, not evidence: * unlike the never-swept admin log, the ops-failures table self-prunes on write in * every adapter, so it can never grow without bound and needs no cron wiring or * operator decision. 90 days comfortably outlives any incident follow-up (a * Cloudflare ticket round-trip) while keeping directory storage flat. */ export declare const OPS_FAILURE_RETENTION_DAYS = 90; /** * How long a sweep-run row is kept (#1232). Much shorter than ops failures: this * is high-frequency telemetry (a pass every few minutes times every connection * and schedule), and its job is the recent-runs strip plus an incident window — * 14 days covers both. Pruned on write in every adapter, like ops failures and * for the same reason: the table stays bounded even where no scheduled pass runs. */ export declare const SWEEP_RUN_RETENTION_DAYS = 14; /** * How long an issue row outlives its last occurrence (#1233). Deliberately longer * than the 90-day evidence beneath it: an issue is the compressed memory of a * failure class, and "we saw this five months ago" is exactly what a regression * needs to be recognizable. Pruned on write like everything else here. */ export declare const ISSUE_RETENTION_DAYS = 180; /** Filter for `listIssues` (#1233). Bounded by `limit` only — see the verb's doc. */ export interface IssueFilter { status?: IssueStatus; operation?: string; code?: string; limit?: number; } /** What the sweep hands `recordSweepRun`. `id`/`at` are stamped by the adapter. */ export interface SweepRunInput { kind: SweepRunKind; /** The identity swept: a connection id, or `:` for a schedule. */ unit: string; outcome: SweepRunOutcome; tenantId?: TenantId | null; scopeId?: ScopeId | null; vertical?: string | null; version?: string | null; operation?: string | null; /** The freshness row's event type (#1232) — its own dimension, never `operation`. */ eventType?: string | null; /** The newest matching evidence at evaluation time (#1232); null = never observed. */ observedAt?: string | null; connectionId?: string | null; error?: string | null; elapsedMs?: number | null; /** * When the unit was actually swept. A DRAINED batch passes the pass time it * carried — the drain runs up to a cron window later, and stamping drain time * would skew every freshness read. Unset = now, the direct-write case. */ at?: string | null; /** * The platform-intent id a drained batch arrived under — the dedupe key. The * adapters enforce UNIQUE (requestId, unit) with an ignore-on-conflict write, * so a replayed drain (a settle that failed in transport, a partial batch * re-run) writes nothing twice. Unset (the direct sweep path) dedupes nothing: * NULLs are distinct under the unique index, exactly as intended. */ requestId?: string | null; } /** Filter for `listSweepRuns` — cursor/order/limit exactly as `OpsFailureFilter`. */ export interface SweepRunFilter { kind?: SweepRunKind; /** The per-unit walk: last run and the recent-runs strip both start here. */ unit?: string; outcome?: SweepRunOutcome; tenantId?: TenantId; scopeId?: ScopeId; vertical?: string; connectionId?: string; since?: string; until?: string; limit?: number; cursor?: string; /** Default 'desc' — the strip and "last run" both read newest-first. */ order?: 'asc' | 'desc'; } /** * One migration as the scope actually applied it (#1236) — the module it belongs * to, its version, and WHEN it ran. The instant is the fact nothing could read * before: a schema change is the most consequential thing that happens to a * scope, and until now its timing lived only in a row nobody selected. */ export interface AppliedMigration { moduleId: string; version: string; /** ISO instant the migration committed, or null for a row written before the column. */ appliedAt: string | null; } /** * What a failure path hands `recordOpsFailure`. `id`/`at` are stamped by the * adapter (ULID + now), everything else by the transport at the catch site. */ export interface OpsFailureInput { actor: PlatformActorId; /** Semantic where the route knows it (`deploy.upload`), `METHOD /route/:path` otherwise. */ operation: string; stage?: string | null; tenantId?: TenantId | null; scopeId?: ScopeId | null; vertical?: string | null; /** The version-registry id the failure happened under — the signals `version` dimension (#1231). */ version?: string | null; /** The HTTP status the failure was answered with (or carried from upstream). */ status?: number | null; /** WHO refused (#1233) — from `attributeFailure` at the catch site, never re-derived from the message. */ origin?: PlatformRequestFailureOrigin | null; /** The taxonomy code when the refusal was one of ours — the fingerprint's error shape. */ code?: ErrorCode | null; message: string; /** The upstream provider's trace reference, when the message carried one. */ reference?: string | null; } /** Filter for `listOpsFailures` — cursor/order/limit exactly as `AuditLogFilter`. */ export interface OpsFailureFilter { tenantId?: TenantId; scopeId?: ScopeId; vertical?: string; /** The version-registry id — what the release-health read narrows by. */ version?: string; operation?: string; /** The taxonomy code — what an issues-style grouping narrows by (#1233). */ code?: string; /** The exemplar walk (#1233): every recorded row in one issue's group. */ fingerprint?: string; /** Exact match — the lookup a CI log's `reference = ` line lands on. */ reference?: string; since?: string; until?: string; limit?: number; cursor?: string; /** Default 'desc' — an operator asks "what broke lately", not "what broke first". */ order?: 'asc' | 'desc'; } export interface AuditLogFilter { tenantId?: TenantId; scopeId?: ScopeId; actor?: PlatformActorId; /** One action or any of several. */ action?: AdminAction | AdminAction[]; /** Inclusive lower / exclusive upper bound on `at` (ISO 8601). */ since?: string; until?: string; /** * Page size. Unset means unbounded — kept as the default because the read is * `AdminLogEntry[]`, and a silent cap would let a caller mistake a truncated * page for the whole log. The console always passes one. (The log is never * swept — it is the compliance witness, control-plane.md §4.4/§4.8 — so the * bound against dumping an ever-growing table lives on the HTTP read surface, * `GET /admin-log`, which DEFAULTS a page rather than leaving it unbounded.) */ limit?: number; /** * Page anchor: the `id` of the last entry of the previous page. Entries are * returned strictly after it in `asc` order, strictly before it in `desc` — * ULID order is chronological, so the cursor is the entry id itself and needs * no separate encoding. There is no `nextCursor`: it is `entries.at(-1)?.id`. */ cursor?: string; /** * Default 'asc' — oldest first, preserving the ordering the log shipped with. * The console reads 'desc'. */ order?: 'asc' | 'desc'; } export interface ScopeHost { /** * Mint a capability stub for a principal. Validates the (tenantId, scopeId) * pair against the directory — a mismatched pair fails closed (K-3), it never * resolves to another tenant's scope. `options` attaches harness-level * observers (`ScopeStubOptions`); they carry no authority and change nothing * about what the stub may do. */ getScope(principal: PrincipalId, tenantId: TenantId, scopeId: ScopeId, options?: ScopeStubOptions): Promise; /** * Mint a stub that ACTS AS a principal, with the staff actor preserved (K-42, * #868) — the supported way to see what a named person sees. * * A fourth door beside the principal, connection and system ones, and a door * rather than a flag on `getScope` for the reason each of those is: what * differs is the AUTHORITY, and an authority that arrives as an optional * parameter on the ordinary path is one a caller can forget to consider. * * The session was opened through `HostAdmin.beginImpersonation`, which recorded * it in the admin log first — so by the time a stub exists there is already a * durable row saying who acquired the ability to act as whom, and why. This * door re-reads the session and refuses an ended, expired or wrong-scope one * (K-3's fail-closed pair check), and every `invoke` through the returned stub * re-reads it again: a capability that outlived its time box is not time-boxed. * * Inside, the operation is ordinary. Permission checks answer about the * IMPERSONATED principal against the ordinary checker — there is no override * branch, exactly as there is none for `system:` — while the outbox, * the denial log and the platform-intent journal each keep both actors. A * `read-only` session additionally refuses the effecting verbs and its * transaction is rolled back rather than committed. */ getImpersonatedScope(session: ImpersonationSessionId, tenantId: TenantId, scopeId: ScopeId, options?: ScopeStubOptions): Promise; /** * The entry scope-lifecycle transition (control-plane.md §4.2): idempotent, * journaled, audited. Requires an existing ACTIVE tenant — a scope with no * tenant record is the "tenant is an FK string" hole §4.1 closes, so it fails * closed. Jurisdiction is fixed here forever (K-7). */ provisionScope(actor: PlatformActorId, input: ProvisionScopeInput): Promise; /** * Mint (or idempotently re-resolve) a **per-tenant relational store** and return the * platform-minted handle (#301). The platform — never the vertical — does this, because * on Cloudflare it holds the credential that creates a D1 (D-34); the vertical only ever * OPENS what it is handed (`openTenantStore`). Idempotent: called again for the same * (tenant, vertical, binding) it returns the existing store's handle rather than minting * a second one, so a retried provision cannot orphan a database. * * The returned `handle.ref` is opaque — a D1 `database_id` on Cloudflare, a per-tenant * `.sqlite` path token on the pure adapter — and is what closes the ownership gap a * bundle-chosen id left open (self-serve-deploy.md §4): the id is minted here, not declared. */ provisionTenantStore(actor: PlatformActorId, input: TenantStoreProvisionInput): Promise; /** * Open a per-tenant relational store the platform minted (#301) for reads/writes — the * request-time and provision-time reach the vertical uses (e.g. to run its OWN store * migrations against a freshly-handed store before the provision callback returns, * preserving the K-31 fail-closed/idempotent/retry ready-gate). Takes the opaque handle * from `provisionTenantStore`; never parses `ref` in vertical code. */ openTenantStore(handle: TenantStoreHandle): TenantRelationalStore; /** * Mint (or idempotently re-resolve) a **per-tenant blob store** (#473) — the byte home * for the attachment surface. Same ownership story as `provisionTenantStore`: the * platform holds the credential that creates an R2 bucket (D-34), the builder declares * only the NEED (`runtimeNeeds.blobStores`), and the returned `handle.ref` is opaque — * an R2 bucket name on Cloudflare, a per-tenant directory token on the pure adapter. * Idempotent on (tenant, vertical, binding) via the blob-store ledger. */ provisionBlobStore(actor: PlatformActorId, input: BlobStoreProvisionInput): Promise; /** * Mint the attachment surface for a principal on a scope (#473) — the runtime consumer * of the manifests' `attachmentTargets`. Same fail-closed (tenantId, scopeId) gate and * lifecycle checks as `getScope`; the returned surface carries the ambient principal, so * every read is `check(target.readPermission, entity)` — proof path included — before a * single byte is served, and every mutation checks the target's write key the same way. * * Deliberately NOT on `ScopeStub`: bytes must never ride the structured-clone invoke * pipe through the scope's strict serialization. Metadata facts go inside the scope * (serialized, transactional, spine event included); bytes go to the per-tenant blob * store the platform minted. Throws when no blob store is configured/provisioned for * the scope's vertical rather than pretending — the K-31 fail-closed posture. */ attachments(principal: PrincipalId, tenantId: TenantId, scopeId: ScopeId): Promise; /** * Provision a NEW scope and load a `ScopeDump` into it — the write side of * `exportScope` and the fork primitive (docs/architecture/preview-and-snapshots.md §3): * a preview/snapshot is a fresh scope carrying a copy of another's data. * * The new scope's schema, rows, AND migration frontier come from the dump verbatim * (drop-then-replay) — NOT from running the vertical's migrations. A fork must be a * faithful copy at the *source's* frontier, which is the whole point: you can then * bind a different version and roll ITS migrations forward on the copy, forward-only * law intact (§4). Provisions → loads → activates, so the result is a ready scope. * * Same `PlatformActorId` and audit as `provisionScope`, and it inherits its * fail-closed tenant gate (the dump's own `tenantId`/`scopeId` are provenance, never * the authority — `input` says where the copy lands). */ importScope(actor: PlatformActorId, input: ProvisionScopeInput, dump: ScopeDump): Promise; /** * Load a `ScopeDump` into an EXISTING scope in place — the restore/backout half of * `exportScope` (preview-and-snapshots.md §8). Same drop-then-replay as a fork: the * dump's schema, rows AND migration frontier replace the scope's wholesale, so a * restore rewinds data faithfully and the forward-only migration law still holds on * the next bind (newer migrations roll forward from the dump's frontier). * * Refuses an unknown scope — restore never creates one; that is `importScope`. The * dump's own `tenantId`/`scopeId` are provenance, never the authority: the caller * says where it lands. Audited as `restoreScope` with the dump's provenance. */ restoreScope(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, dump: ScopeDump): Promise; /** * Snapshot a scope — fork its current data into a new scope and return that scope's * id. A thin composition of `exportScope` + `importScope` (preview-and-snapshots.md * §3): the new scope is `kind: 'archive'` by default, carries fork provenance * (`forkedFrom`/`forkedAt`), and is bound to the SOURCE's current version so it is a * runnable copy at the same frontier — a true "the scope as it was", not loose data. * * This is the primitive behind a manual "Snapshot" and behind `bindScopeVersion`'s * `snapshot` option (the automatic fork-before-promote). It provisions a scope in the * same tenant + jurisdiction as the source. */ snapshotScope(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, opts?: { kind?: string; expiresAt?: string; }): Promise; /** * Reap a fork — delete its storage AND its directory row (preview-and-snapshots.md * §3/§9). The one sanctioned hard delete on the platform, and deliberately narrow: * it REFUSES any scope whose `forkedFrom` is null. A fork is an ephemeral copy — * its deletion reclaims storage and PII without touching spine history; every * primary scope keeps the platform's tombstone-only rule (`archiveScope` et al). * * Removes, in order: the scope's hostname bindings (a reaped preview URL must stop * resolving), the directory row, and the scope's own storage (the DO's SQLite / the * adapter's file). Audited as `deleteSnapshot` with the fork's provenance in the * entry, so the log records what was reaped and where it came from. */ deleteSnapshot(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId): Promise; /** Enforcement-input writes: roles, assignments, grants, membership. */ readonly admin: HostAdmin; /** Register a module: validates the manifest, applies migrations lazily per scope. */ registerModule(registration: ModuleRegistration): void; /** * Register an executor for an event type (K-22 §4.2). Host code, not module code: * `id` names the delivery target in the kernel's at-least-once journal, the same * way a module id does for a consumer, so an executor is redelivered until it * succeeds and never runs twice for one event once it has. * * Executors are dispatched **inline after commit**, with the outbox as the * durability and retry backstop. The contract stays eventually consistent — that * is what makes it correct under crash — but the common case completes inside the * originating request, so "requested but not yet effected" is a rare-case fallback * rather than the normal experience. * * **A failing handler never fails the operation** (#100). The operation already * committed; the delivery did not. Those are different facts, and reporting the * second as the first told a caller their work had been rolled back when it had * not. A failure is retried with backoff, dead-lettered at `maxAttempts`, and * surfaced through `drainDue`/`executorDeadLetters` — never thrown at whoever happened * to be holding the request. */ registerExecutor(id: string, eventType: string, handler: ExecutorHandler, retry?: ExecutorRetryPolicy): void; /** * A scope stub whose authority is a CONNECTION rather than a person (#97). * * This is the inbound half of the connector seam: the path by which a * provider's callback, or a poll of a provider's state, writes back into a * scope. `getScope` demands a `PrincipalId` and a provider is not one, which * is why a connector could dispatch a document and then not record that it * had — and why an at-least-once retry would send a second copy. * * **Authority is inherited, not re-declared.** A connection is keyed * (tenant, vertical, provider), so this refuses any scope outside that * tenant, and any scope not running that vertical. What the connection may * then DO is an ordinary permission check against `connection:` grants — * one enforcement path, one place to read, one way to revoke. * * `ctx.principal` on the resulting stub carries the connection id so the type * holds, but it is **not a person**: an operation invoked this way should read * the event actor (`{ connection }`), and a module that attributes domain * data to `ctx.principal` will be recording a connector. */ getConnectorScope(connectionId: ConnectionId, scopeId: ScopeId): Promise; /** * The attachment surface for a CONNECTION on a scope (#476) — the connector's * door to `attachmentTargets`, the mirror of `getConnectorScope` for bytes. * * A connector runs sanctioned egress (it holds the provider credential), so it * is the only code that can fetch a provider artifact — the sealed signed PDF a * signing flow leaves at the provider, a document a webhook references. Landing * those bytes is exactly what `attachments()` does, but that surface is minted * per `PrincipalId` and a connection is not a person; and bytes cannot ride * `getConnectorScope`'s `invoke` (the structured-clone pipe #473 exists to * bypass). This is the missing seam: the same `ScopeAttachments` surface, but * every gate checked as the connection. * * **Same inheritance and enforcement as `getConnectorScope`.** Refuses a scope * outside the connection's tenant or not running its vertical; every * upload/remove is gated by the target's `writePermission` and every read by its * `readPermission`, checked against `connection:` grants — so a connection * lands an attachment only where it was granted the write key (it appears in the * permission diff like any grant). `createdBy` on the record is the connection, * not a laundered principal. Throws when no blob store is provisioned for the * scope's vertical, exactly like `attachments`. */ getConnectorAttachments(connectionId: ConnectionId, scopeId: ScopeId, /** * Build the surface for ONE delivery (#726 remedy B). When set, `open` is admitted * by ownership of the entity this event names — resolved against the scope's own * spine, never taken on the caller's word — instead of by a standing grant. Absent * (the return path, a poll driver) the ordinary permission check applies. */ forEvent?: { eventId: string; }): Promise; /** * What this scope's own tuples say a connection may do (#726 gap 1). * * Every other authority in the model is inspectable from where the vertical sits: * the permission surface is diffed at promote, role tuples are readable from the * scope, entitlements and identity links are projected and read back locally. A * connection's grants were the exception — writable from the platform, readable only * with staff access to the control plane — and they are the authority behind the one * actor that is not a person. #716 found `protocol:attach` missing from the demo * Scrive connection after months of silently failing the sealed-copy landing; nothing * in the deployment could have answered the question that would have caught it. * * **The scope's own answer, not the directory's.** These are the delivered * `connection:` / `granted:` tuples — the same rows the permission checker * reads, so what this returns is what would actually be enforced here, including a * scope whose delivery is behind the directory. The directory's view is a different * fact and lives on `HostAdmin`; a caller asking "may this connection act HERE" wants * this one. * * Live grants only: revoked tuples are tombstoned rather than deleted (K-21) and * expired ones are past their `expires_at`, and neither would be enforced, so neither * is reported. */ connectionGrantsInScope(tenantId: TenantId, scopeId: ScopeId): Promise; /** * A scope stub whose authority is a MODULE acting on a timer (#383) — the * scheduler's door, the mirror of `getConnectorScope`. * * This is how a declared schedule invokes an operation on a scope without * signing in as a person. `getScope` demands a `PrincipalId`; a schedule is not * one, and modelling it as a human is exactly the attribution laundering #97 * refused — after a night's run the audit log could not tell the scheduler from * an admin who sat down at 03:00. * * **Authority is inherited, not re-declared.** The stub refuses any scope not * running `moduleId`'s vertical, and any scope that is not `active`. What it may * then DO is an ordinary permission check against `system:` grants — * one enforcement path, `ctx.check` stays the single gate, no bypass. Events it * emits are stamped `{ system: moduleId }`. `ctx.principal` carries the module id * so the type holds, but it is **not a person**. */ getSystemScope(moduleId: ModuleId, tenantId: TenantId, scopeId: ScopeId): Promise; /** * The recurring-work declarations of every module registered on this host (#383) * — each module's id, the vertical it belongs to, and its `schedules`. Sync like * `migrationFrontier`: code-time bookkeeping derived from the registered * manifests, not directory state. The platform sweep reads this to discover what * to run, then enumerates each vertical's live scopes. */ registeredSchedules(): ScheduleRegistration[]; /** * Run every schedule that is DUE for this scope (#383) — the recurring-work * driver, the fleet-maintenance sibling of `drainDue`. * * Opens the scope once, and for each of `moduleId`'s declared schedules whose * cadence has elapsed since its last run (kernel-tracked spine state), invokes * the operation through the system door with its declared `input`, then records * the run. Idempotent and safe when nothing is due: a schedule inside its cadence * window is skipped, not re-run. Takes no actor — this is maintenance, the same * class as `drainDue`/`migrateScope`; the invocation itself is attributed to the * system actor. A single schedule's failure is reported, never thrown, so one bad * operation cannot stop the others on the scope. */ runDueSchedules(moduleId: ModuleId, tenantId: TenantId, scopeId: ScopeId): Promise; /** * The declared freshness expectations, per module (#1232). OPTIONAL and * feature-detected like `registeredSchedules` is by the sweep: a pre-#1232 * host degrades to "no expectations", never a crash. Its OWN registry rather * than a widening of `ScheduleRegistration`, deliberately: every sweep driver * filters registrations by `schedules.length > 0`, so a freshness-only module * hung off the schedule registry would be dropped before it was ever asked. */ registeredFreshness?(): FreshnessRegistration[]; /** * Evaluate one module's freshness expectations on one scope (#1232): read the * scope's own outbox (`MAX(occurred_at)` per declared type — local, and indexed * for exactly this read), judge each against its window, and return what should * be RECORDED this pass — verdict changes plus the hourly heartbeat * (`FRESHNESS_HEARTBEAT_MINUTES`); see `FreshnessReport`. * * Deliberately NOT gated on the module's system grant: freshness is a read of * the scope's own outbox and needs no authority beyond "this module is * registered here and the scope is active". A grant gate would silently disable * it for exactly the module shape that needs it most — freshness with no * schedules, hence no projected `system:` tuple — reinventing the * unfalsifiable zero (#49) this record exists to end. */ checkFreshness?(moduleId: ModuleId, tenantId: TenantId, scopeId: ScopeId): Promise; /** * Register a connector — an executor that also gets a per-tenant credential * and sanctioned egress (#101, design/connections.md §4.1). * * Rides the same hardened dispatch, journal and retry policy as * `registerExecutor`; the difference is only what the handler is handed. Kept * as a second registration rather than widening `ExecutorHandler` because the * two really are different capabilities, and a membership executor should not * be handed the machinery to call the internet. */ registerConnector(id: string, eventType: string, handler: ConnectorHandler, options?: ConnectorOptions): void; /** * Run every executor delivery that is due for this scope — the retry driver. * * Inline dispatch after an operation covers the common case, but a delivery * that failed has no way back on its own: before this existed, retry happened * only if someone happened to invoke another operation on the same scope, so a * quiet scope could hold a failed effect forever with nothing reporting it. * * Call it from whatever scheduling the deployment has — a cron trigger, a * Durable Object alarm, a dev-server timer. Idempotent and safe to call when * nothing is due. */ drainDue(tenantId: TenantId, scopeId: ScopeId): Promise; /** * Register a JOB — long, resumable work (#1577). Host code, like * `registerExecutor`, and for the same reason: a walk of an external system * holds credentials and makes network calls, which module code may not. * * `moduleId` and `name` are two thirds of the coalescing key; the third is the * `instance` a run names. The handler is the body of ONE PASS — see `JobHandler` * and this driver's contract in `job-run.ts`. `retry` is the DEFAULT policy for * the job's steps; a step may pass its own. * * The fourth driver, and a SIBLING of the three that already exist rather than a * widening of any of them. An executor retries one delivery whole; a schedule * fires one operation that must finish; the platform sweep does a pass of * maintenance. None of them can stop halfway through an hour and carry on. */ registerJob(moduleId: ModuleId, name: string, handler: JobHandler, retry?: ExecutorRetryPolicy): void; /** * Start a run, or JOIN the one already in flight for the same * `(module, job, instance)` — the coalescing half of the driver. * * A start against a live key returns THAT run: its id, its cursor, its counters. * Not a second row, and not a refusal — asking for a re-index while one is * running is a reasonable thing to do, and the useful answer is the walk that is * already happening. Coalescing is the driver's decision, never a unique index: * a run whose worker was evicted is still `running` and MUST be restartable, so * the constraint cannot be "one row ever" (which is what `_substrat_sweep_runs` * legitimately carries, being a receipt rather than a cursor). * * The payload is held to the queue-safety rule at this boundary and refused with * the offending path named, the way an operation's input failure already is: ids * and configuration, never bytes, class instances or functions. * * Fleet maintenance, no actor — the same class as `drainDue`. What the run may DO * is decided inside it, by an ordinary `ctx.check` against `system:`. */ startJobRun(tenantId: TenantId, scopeId: ScopeId, input: StartJobRunInput): Promise; /** * Advance every DUE run on this scope — the driver proper, the resumable sibling * of `drainDue`. * * One pass per run by default: a pass does a bounded chunk, commits a cursor, and * the next call resumes from it, so an hour-long walk is never one call anybody * has to keep alive. `maxPasses` raises that for a caller with a budget; `limit` * caps how many runs one call picks up. * * A step that exhausts its retries fails ITS run and is reported — never thrown, * so one bad run cannot stop the ones behind it. Idempotent and safe when nothing * is due. A run whose job this host does not register is left untouched: it may * belong to another deployment, and failing it would destroy a resumable run * because the wrong process looked at it. * * Deliberately NOT gated on the module's `system:` grant the way * `runDueSchedules` is. A schedule is started by the platform and the grant is * the switch that says whether it should be; a run was started explicitly, and a * grant gate here would STALL it silently rather than refuse it. The authority it * exercises is checked where it is used, inside the operations its steps invoke. * * **One driver per scope at a time**, and that is a bound the caller holds, not one * this enforces — there is no lease. Coalescing stops duplicate RUNS; two concurrent * calls of THIS would advance the same run together. Every topology the driver is * built for gives a scope one tick (the platform sweep does one call per scope, a * scope DO's alarm fires for its own), so the bound holds by construction; the full * argument, and what an overlap would actually cost, is in `job-run.ts`. */ runDueJobs(tenantId: TenantId, scopeId: ScopeId, options?: { maxPasses?: number; limit?: number; }): Promise; /** * The scope's run records, newest first — "the outcome has to be legible to an * operator afterwards", as a read rather than as a promise. * * Every run, in whatever state it settled, with the cursor it reached, the * counters it accumulated and the error that stopped it. Fleet maintenance, no * actor — the same class as `listPlatformRequestHistory`, which it is shaped * after for the same reason: a record nobody can read is not evidence. */ jobRuns(tenantId: TenantId, scopeId: ScopeId, filter?: JobRunFilter): Promise; /** * Execute ONE connector delivery with this host's directory, credentials and egress — * the platform half of #574 phase 3. A CP-less host routes each connector delivery * onto the platform-requests surface as a `connector:` intent; the * platform's drain calls this to run the same handler a self-host would have run * in-process, against the SAME `ConnectorContext` shape (ambient tenant/vertical, the * opened connection, sanctioned egress). No journal here: the intent row IS the * journal — the drain settles it done/pending from this call's outcome, and * at-least-once still requires the handler's own idempotency (the dispatch ledger). * Fleet maintenance, no actor, same class as `drainDue`. Fails closed on a host that * cannot reach the connection directory. */ dispatchConnector(tenantId: TenantId, scopeId: ScopeId, handler: ConnectorHandler, event: DomainEvent, options?: { timeoutMs?: number; }): Promise; /** * The deployed migration frontier for the modules registered on this host — * the number a scope's `schemaVersion` must reach to be current (§5.3, #49). * Sync like `registerModule`: it is code-time bookkeeping, not directory state. */ migrationFrontier(): MigrationFrontier; /** * Attempt a scope's pending migrations NOW — the reconciliation sweep's wake + * retry affordance (§5.3, #49). * * Distinct from the lazy wake in three deliberate ways. It takes no principal * (this is fleet maintenance, the same class as `drainDue` — no actor, not * audited; the outcome lands in the directory's migration-state projection * either way). It returns a structured outcome instead of throwing, because * for a sweep a failed migration is state to report and back off from — the * request paths keep their rejection so operations still fail closed. And it * MUST defeat any per-instance memoisation of a failed attempt (the * Cloudflare ScopeDO caches its migration promise, so a plain re-wake would * return the cached rejection forever): a call here is always a fresh * attempt of whatever is still pending. * * Gates: the (tenantId, scopeId) pair is cross-checked and fails closed on a * mismatch (K-3). Allowed on `active` AND `provisioning` scopes — a scope * stuck in provisioning because its migration failed is precisely a sweep * target — refused for `suspended`/`archived`, which are deliberate states * the sweep must not disturb. */ migrateScope(tenantId: TenantId, scopeId: ScopeId): Promise; /** * Executor deliveries that exhausted their attempts, oldest first — the evidence a * dead-letter is a decision rather than a disappearance. */ executorDeadLetters(tenantId: TenantId, scopeId: ScopeId): Promise; /** * The scope's PENDING platform intents (platform-intents.md) — rows a vertical enqueued via * `ctx.requestPlatform` awaiting the platform's drain. Fleet maintenance, no actor (the same * class as `drainDue`). The platform reads these, executes each with `HostAdmin` authority, and * journals the outcome via `settlePlatformRequest` — the read-here/effect-there executor shape. */ listPlatformRequests(tenantId: TenantId, scopeId: ScopeId): Promise; /** * The scope's intent JOURNAL — every intent in whatever state it settled, newest first (#618). * * `listPlatformRequests` answers the drain's question and so returns only `pending`; that made * a settled intent unreadable from anywhere but the scope's own spine table, which is how a * connector's full `last_error` ("HTTP 409 Authentication to sign for participant #1 requires * valid personal number field") ended up retained, correct, and reachable only by hand-written * SQL. This is the read that surfaces it: `kind` narrows to one intent family * (`connector:scrive`), `status` to one outcome, `limit` to a recency window. * * Fleet maintenance, no actor — same class as the pending read it complements. */ listPlatformRequestHistory(tenantId: TenantId, scopeId: ScopeId, filter?: PlatformRequestFilter): Promise; /** * Journal a platform-request outcome after the coordinator ran it: `done`, `failed` (terminal), * or `pending` (transient — retried on a later drain). `result` persists across retries (a * value written on an earlier pass survives an omitted one), carrying handler output such as a * minted sibling scope id for two-phase idempotency. */ settlePlatformRequest(tenantId: TenantId, scopeId: ScopeId, id: PlatformRequestId, outcome: { status: PlatformRequestStatus; result?: unknown; lastError?: string | null; /** WHO refused (#841). Omitted by a caller too old to attribute — stored as NULL. */ failure?: PlatformRequestFailure | null; }): Promise; /** * Live reads (#938) — a subscription to this scope's change feed, or absent. * * **Optional on the contract, and that is the whole design.** A live read needs a * transport that can hold a connection open for longer than a request, and only one * host has one: on Cloudflare a scope IS a Durable Object, which the runtime keeps * addressable between requests and can hand a `WebSocketPair` to. The pure host is a * function call inside somebody else's process — there is no connection for it to * hold and nothing to wake when an event lands — so `SqliteScopeHost` declares * `liveReads?: never`, the `clock?: never` precedent (`CloudflareScopeHostOptions`) * applied in the other direction: there, the hosted adapter refuses an option it * cannot honour; here, the pure adapter refuses a surface it cannot honour. A * mistaken `host.liveReads!.subscribe(…)` against SQLite is a compile error rather * than a promise that never resolves. * * What this costs, stated rather than hidden, exactly as the clock seam states it: * live-read behaviour is held to its tests on the Cloudflare host ONLY. There is no * contract suite for it, because a contract suite is a claim that both adapters * answer the same way, and on this one they deliberately do not. * * A caller therefore asks rather than assumes: * * ```ts * const live = host.liveReads; * if (!live) return new Response('live reads are not available here', { status: 501 }); * return live.subscribe({ tenantId, scopeId, principal, request }); * ``` */ readonly liveReads?: LiveReadSurface; /** Bare operation registration (tests, glue). Names are module-namespaced: 'workorder/create'. */ defineOperation(name: string, handler: OperationHandler): void; close(): Promise; } /** * The live-read surface (#938): one `Upgrade` in, one long-lived subscription out. * * **What crosses it is an invalidation, never a payload.** A frame names the event * type and the `(entityType, entityId)` it was about, and the client re-reads that * entity through the ordinary operation it already calls. That is what keeps the * change feed from quietly becoming a second, ungoverned read API: every field a * subscriber ever sees came back through the declared read surface, with that read's * own permission check, its own field-level omissions and its own PII handling. * * **Every frame is filtered per subscriber, after the commit and after the check.** * The host offers each committed event to each subscriber and delivers it only if * that subscriber passes the entity type's declared `liveTargets.readPermission` ON * THAT ENTITY — the same walk `ctx.check(key, entityRef)` makes, through the same * evaluator, so an entity-narrowed grant decides a push exactly as it decides a read. * An entity type no module declared reaches nobody. Knowing that a row exists and * changed at 14:02 is information about that row, so "the body was empty" is not a * defence: the filter runs whether or not there is a payload to withhold. * * **Generic over the runtime's request and response, because the kernel names * neither.** This package has one dependency and no DOM or workers lib * (`docs/architecture/dependency-policy.md`), which is why `FetchLike` above describes * a `fetch` structurally rather than importing one. The same rule applies here, and it * is load-bearing in the other direction too: what a caller gets back must be the * runtime's OWN response object, because a Worker's handler has to return one. A * structural stand-in would describe it correctly and still not be returnable. So the * type travels through instead of being restated — `CloudflareScopeHost` binds it to * the real `Request`/`Response`, and code holding the bare contract gets `unknown` back * and narrows at its own mount point, where it knows which host it is on. */ export interface LiveReadSurface { /** * Accept a WebSocket `Upgrade` and subscribe the principal to the scope's changes. * * The caller is the vertical, and it has already done the one thing this surface * cannot do for itself: resolved WHO is asking. The principal is an authenticated * fact the vertical's session carries, exactly as it is for an `invoke` — this * surface trusts it the same way and no further, which is why every frame is still * checked against it individually rather than a subscription being authorized once. * * Returns the 101 to hand back to the client, or a refusal to return as-is: a * request that is not an upgrade gets 426, and one arriving over a hop that cannot * carry a socket gets a refusal naming that, so the client can fall back to polling * KNOWINGLY. Neither is thrown, because both are answers to a client rather than * faults of the caller. */ subscribe(input: { tenantId: TenantId; scopeId: ScopeId; /** WHO is subscribing — resolved by the vertical from its own session, never by the client. */ principal: PrincipalId; /** The upgrade request as it arrived, carried whole so the host reads its own headers. */ request: Req; }): Promise; } /** * The only thing this surface needs of an incoming request: its headers. * * Narrow on purpose. A live-read door reads `Upgrade` to know what is being asked for * and `cf-connecting-o2o` to know whether this hop can carry it, and nothing else — it * does not route on the path, read the body, or care about the method. Describing * exactly that much is what lets a test drive the door with a two-line object instead * of constructing a runtime request. */ export interface LiveUpgradeRequest { readonly headers: { get(name: string): string | null; }; } /** * One frame on a live read (#938) — the wire shape, so both ends name it once. * * Deliberately not the outbox envelope. An envelope carries the payload, the * authorization chain, the impersonation stamp and the PII class, and none of those * belong on a channel whose contract is "re-read it yourself". What a subscriber * needs in order to act is what is here: which entity changed, and what happened to it. */ export interface LiveChange { /** Always `'change'` today; a field rather than an assumption, so a second kind can be added. */ readonly kind: 'change'; /** * The event id — a ULID, so frames sort and a repeat is recognisable. * * **Not a gap detector, and a client must not use it as one.** ULIDs are ordered but * not contiguous, so "the next id is not the one after this" says nothing on its own. * More to the point, gaps here are the NORMAL case and the deliberate one: the * permission filter withholds every event this subscriber may not read, so the ids it * receives are a sparse subset of what the scope emitted, by design. A client * inferring missed updates from the spacing would be reading someone else's * entitlements as its own packet loss. * * What it is good for: ordering frames that arrive out of order, and discarding one * it has already acted on. Detecting a genuinely missed update would need an * authorized contiguous cursor — a per-subscriber sequence, counted after the filter — * which this protocol does not have and should not grow by accident. */ readonly id: string; /** The emitted event type, e.g. `'ticket0/message-posted'`. */ readonly type: string; readonly entityType: string; readonly entityId: string; /** When the event was emitted (ISO 8601), i.e. the emitting operation's instant. */ readonly at: string; } /** * Refuse a cutoff in the future — at the HostAdmin boundary, not only at the HTTP door. * * A future instant clears the stamps on rows the drain shipped AFTER the rebuild, which * is the exact double-write the required instant exists to prevent: those payloads go to * the new table a second time. The control-plane route checked it, and that was enough * only while the route was the single way in — `redrainEvents` is a public `HostAdmin` * verb, so the fleet script and any other in-process caller reach the adapters directly. * An invariant one caller enforces is a convention; this is the same rule where every * caller must pass it. * * `now` is supplied rather than read so the pure host can pass its injected clock and a * test can pin the boundary instead of racing the wall clock. */ export declare function assertRedrainWindow(drainedBefore: string, now: string): void; export {}; //# sourceMappingURL=scope-host.d.ts.map