import type { ScopeStoreStubOptions, StoreErasureReplay, StoreStub } from './scope-store.js'; import type { ModuleLog } from './module-log.js'; import type { BoundOperations } from './module-operations.js'; import type { DeliveryRefusal } from './delivery-refusal.js'; import type { ScopeRoleHolder } from './scope-role-admin.js'; import type { ScopeCopyBackfillResult } from './scope-copy-ledger.js'; import type { SubjectRedactionCounts } from './subject-redaction.js'; import type { OnBehalfOf, ExportReadInput, ExportedBatch, ImportBatch, ImportedEvent, ImportResult, ImportState, ImportCursorMove, ImportCursorMoved, AdminAction, BecomeCapabilityInput, CapabilityExchange, CapabilityFilter, CapabilityId, CapabilityPage, MintedCapability, ListPage, ConnectLink, ConnectLinkConsume, ConnectLinkFilter, ConnectLinkKey, ConsumeConnectLinkInput, MintConnectLinkInput, Connection, ConnectionFilter, ConnectionId, ConnectionGrant, ConnectionGrantRecord, ConnectionSecret, CreateConnectionInput, OpenConnection, ProjectedConnectionGrant, ProjectedConnectionKey, AccessLogEntry, DelegatedReadRecord, OwnerTransferAudit, MemberChangeAudit, PeerBindingAudit, CopyMarkAudit, BindHostnameInput, AdminLogEntry, OpsFailureEntry, PlatformRequestDrainTotalsEntry, SweepRunEntry, SweepRunKind, SweepRunOutcome, ModelUsageEntry, ModelUsageSummary, CapabilityGrant, CreateTenantInput, Decision, Instant, DomainEvent, DomainEventInput, PlatformRequestInput, PlatformRequestId, PlatformRequestEntry, PlatformRequestFilter, PlatformRequestStatus, PlatformRequestFailure, PlatformOutcomeEvent, EntitlementGrant, EntitlementGrantInput, EntitlementView, MeterReading, ScopeStorageSample, EntityRef, IdentityLink, IdentityPool, Jurisdiction, ModuleId, ModuleManifest, ScheduleSpec, SystemGrant, PeerGrantsStatusEntry, SystemGrantsStatusEntry, SystemSwitchRecord, SystemSwitch, SystemSwitchResult, PeerCoverage, PeerSwitch, PeerSwitchResult, VerticalCaller, VerticalResolution, PeerBinding, PeerInstanceResolution, CreateOrgInput, Node, Org, OrgId, OrgMembership, PermissionKey, PlatformActorId, ChannelName, ExportBreak, ChannelHistoryEntry, DnsRecord, HostnameBinding, HostnameStatus, PromotionAcknowledgement, BindAcknowledgement, PublishVersionInput, RegisterVerticalInput, VerticalServingState, RouteTarget, DirectoryDump, IdentityMembership, PrincipalId, ResolvedIdentity, RoleAssignment, RoleDefinition, QueryScopeInput, ReadScopeTableInput, Scope, ScopeDump, ScopeDumpInput, SubjectShredReceipt, ScopeQueryResult, ScopeId, ScopeStatus, ScopeTable, DenialFilter, DenialSummary, RefusalFilter, RefusalRecord, PermissionDenial, Coverage, BeginImpersonationInput, ImpersonationFilter, ImpersonationSession, ImpersonationSessionId, ScopeTablePage, StorageShape, Tenant, TenantId, TenantRole, TenantStoreHandle, AttachmentRecord, BlobStoreHandle, Visibility, Vertical, VerticalChannel, VerticalVersion, TenantStatus, Page, CountedPage, FreshnessSpec, EntityHistoryInput, EventFacetInput, EventCauseInput, EventEffectsInput, EffectsTree, InvocationEventsInput, InvocationEvents, DeadLettersInput, DeadLetter, LifecycleFlowInput, LifecycleFlowResult, OperationSeriesInput, OperationSeriesResult, CauseChain, EventFacetResult, HistoryEntry, ErrorCode, PlatformRequestFailureOrigin, IssueEntry, IssueStatus, IssueStatusInput, FindingEntry, FindingFilter, FindingRuleEntry, FindingRuleInput, FindingStatusInput, DeclaredMigration, CheckSubject, EntityGrantShape } from '@substrat-run/contracts'; import type { ConnectionUseOutcome } from './connector-calls.js'; import type { CapabilityVerbs } from './capability.js'; import type { AuditedOperationRef, AuditedOperationRow } from './audit-outcome.js'; import { type EntityStateName } from '@substrat-run/contracts'; import type { ModelUsageFilter, ModelUsageInput, ModelUsageWindow } from './model-usage.js'; import type { FindingPruneReport } from './findings.js'; import type { ScopeStorageAttempt, ScopeStorageFilter, ScopeStorageReadingInput } from './storage-gauge.js'; import type { SealedSecret } from './secret-box.js'; import type { OnSubjectErased } from './module-erasure.js'; import type { SystemSwitchReassert, SystemSwitchReassertOptions, SystemSwitchRecordFilter } from './system-switch-record.js'; import type { SearchHit, SearchOptions } from './search-index.js'; import type { GrantedEntitiesPage } from './grant-scoped-read.js'; import type { EntityVersion } from './entity-version.js'; import type { UndrainedEvents } from './outbox-event.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; /** * Which rows of an archivable entity (#119): `active` when unset, `archived` to read the * archive. Refused for an entity that declares no archive. The bin is `ctx.pageTrashed`. */ readonly view?: Exclude; /** * Also return each row's own cursor, as `rowCursors` (#2073) — asked for by `pageVisible`, * which may stop partway through a page. Off by default, so an ordinary page is unchanged. */ readonly rowCursors?: 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; /** * The SQL door a module holds: `ctx.sql`. * * **The node adapter refuses what a Durable Object refuses, for three limits** (#1741): a * statement over one gets the hosted message, so a vertical's own suite fails where production * would (`DO_SQL_LIMITS`, measured in `adapter-cloudflare/test/do-sql-limits.test.ts`). The * fourth, the `LIKE`/`GLOB` pattern, is a limit of the hosted database only — see the last item. * * - **5** terms in one compound `SELECT` (`UNION`/`UNION ALL`/`INTERSECT`/`EXCEPT`; a * subquery or CTE body counts its own; multi-row `VALUES` is not a compound); * - **100** bound parameters in one statement (the highest number, `?N` included) — bind a * list of unknown length as ONE JSON array and read it with `json_each`, which is what * `inList(values)` from `@substrat-run/contracts` writes for you (#2248): * `` ctx.sql.query(`SELECT … WHERE id IN ${ids.sql}`, ids.params) ``; * - **100 000 bytes** of statement, UTF-8, the whole string; * - **50 bytes** of `LIKE`/`GLOB` pattern, UTF-8 — NOT enforced by an adapter: node's SQLite * allows 50 000, and it can only be emulated by a test preload * (`tools/vitest/like-pattern-limit.cjs` here; `@substrat-run/adapter-sqlite/testing` is the * same patch, published, opt-in for a vertical's own suite, #1770). Doing it in the adapter * would replace `like()` on every connection: a JavaScript call per row, and no `LIKE` prefix * index for self-hosters. * * Only module-facing SQL is judged; see `guardSqlLimits`. */ 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; /** Seal positions for one filtered list under this scope, caller and operation. */ pageContinuation(list: string, query: unknown): { /** Read-only and copy walks cannot persist a hidden budget-stop position. */ writable?: boolean; open(cursor: string): Promise; seal(position: string, hidden: boolean): Promise; }; 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; /** * The structured logger (#1746, #1747). One line per call, stamped by the host with the * tenant, scope, operation, invocation and who the code ran as, and carrying the * TEMPLATE it was written from, so every line from one call site groups as one pattern: * * ```ts * ctx.log.warn('reply to {ticketId} bounced: {reason}', { ticketId, reason }); * ``` * * Keep the template a constant and put the values in `fields`; a template built from * values makes every line its own pattern. Not transactional: a line is written even when * the operation later rolls back, and a call never throws (`module-log.ts` says why). */ readonly log: ModuleLog; /** * The id the platform minted for the request this call belongs to (#2192) — the value of * the response's `x-substrat-request-id` header, the id on every `ctx.log` line and every * event the call emits, and the request the dashboard opens for it. For module code to put * in its own error messages, so a person who saw one can quote it. * * In a consumer, the id of its DELIVERY, the one its async invocation line carries: the * originating request's id when the event is delivered inside that call, and an id minted * for the delivery otherwise — a retry, a drain, or the tail of a call that carried none. * So a consumer's id is never `null`, and on a retry it is one no response carried. In a * schedule run, the id the host minted for that run. `null` only on an operation called * with no `invocationId` (an in-process call: a test, a seed). Opaque — read it only to * repeat it. */ readonly requestId: string | null; /** 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. * * **A row whose identity does not decode comes back as an `UndecodablePlatformRequest`** * (#1637) — its id, kind, status, attempts or time as stored, and why — beside every other row, * instead of throwing for the whole list. Narrow with `isUndecodablePlatformRequest` before * reading anything else. Only a restored dump can hold such a row; a healthy journal never * returns one. */ platformRequests(filter?: PlatformRequestFilter): PlatformRequestEntry[]; /** * 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.** Every event about the entity moves this * but a kernel state move (`ctx.archive`/`unarchive`/`trash`/`restore`, #2191), * which changes no content. Every other event counts, even one that changed * nothing the caller read — a grant top-up, say. 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). * A `permission` that is not a key (a cast past the brand) throws `internal` rather * than returning a denial, and records none (#1642, `assertPermissionKey`). */ check(permission: PermissionKey, entity?: EntityRef): Promise; /** * Page entity ids reachable through this caller's live grants and declared parent edges. * `all` means a node grant makes every entity of this type visible; `incomplete` means * this checker cannot enumerate the path, so callers must use a checked full walk. * An `ids` page is complete only after its cursor becomes null. Ids may repeat across * pages on multiple parent paths: accumulate a Set, and do not treat page lengths as * a count or total. The union of all pages is complete. Every id passes `ctx.check`. */ grantedEntities(permission: PermissionKey, entityType: string, options?: { limit?: number; cursor?: string; }): 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 (SELECT value FROM json_each(?))', * [JSON.stringify(hits.map((h) => h.id))], * ); * ``` * * The ids go in as ONE JSON array, never a `?` per hit: a Durable Object refuses the 101st * bound parameter (#1776), so a list of hits sized by a caller's `limit` must not become the * statement's parameter count. * * **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 hand this * page on as it is: a page of 20 filtered down to 3 is not a page, and its cursor * may be a refused row's. It walks this read with `pageVisible` instead (#2073). * * 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. * * `params.order` unset walks ascending; an operation's declared order arrives * already applied by `operationInputsOf`. A cursor replayed under another sort * or order throws `CursorMismatch` (`reason: 'cursor_restart'`) — #2001, K-44. */ 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, and it leaves the edge live and permanent, clearing any expiry a restored dump * carried. A first link emits nothing; linking back to an edge `relink` moved away from * revives it and records that as one `entity.linked` spine event, since access resumes. * * Checks no permission: the operation does, in its own vocabulary. * * A parent in the TRASH (#119) is refused `not_found`: nothing new hangs off a binned * entity, since it is gone from everyone's view until restored. A MISSING parent of a type that * declares trash gets the identical refusal, so the two cannot be told apart. An ARCHIVED parent * is allowed — archived is filed away and still readable, and whether one may still be added to * is the vertical's rule, not the kernel's. */ link(child: EntityRef, parent: EntityRef): void; /** * Move `child` from parent `from` to parent `to` (#1864) — one atomic replace, so access * follows the move: a grant above `from` stops reaching the child, a grant above `to` * starts, and no check in between sees it parentless. * * - `to` must be a relation declared in `entityRelations`, exactly as `link` requires; * `validation_failed` otherwise. So is a `to` that is the child or lies beneath it — a * move would make the child its own ancestor. * - `from` must be a live parent edge of `child`; `conflict` otherwise. Every other * parent a multi-parent entity has is left alone. * - A `to` in the TRASH is refused `not_found`, as `link` refuses one (#119) — the same refusal a * missing `to` of that type gets. `from` is not: moving a child out from under a binned * parent is how it is rescued. * - `from` equal to `to` is a no-op: nothing written, nothing emitted. * - A `to` that is ALREADY a live parent of `child` is the way to detach one parent of a * multi-parent child (#2044): the `from` edge is tombstoned, the live `to` edge stays * live — no second row, no revive, no `entity.linked` — and `entity.relinked` is still * emitted. Like every edge `link` or `relink` leaves live, `to` is left PERMANENT: a * future expiry on it (which only a restored dump can hold) is cleared, silently. The * child keeps every other parent untouched, expiry included. The contract suite holds * both adapters to it. * - The old edge is tombstoned (K-21), not deleted, and one `entity.relinked` spine event * records the move on the child's timeline, stamped like any event the operation emits. * - Transactional with the operation: a relink whose operation throws never happened. * * **Checks no permission, like `link`.** Who may move what is vocabulary the kernel does * not have, so the operation checks all three ends itself: * * ```ts * assertAllowed(await ctx.check(FOLDER_MOVE, doc)); // may move this document * assertAllowed(await ctx.check(FOLDER_WRITE, from)); // may take it out of here * assertAllowed(await ctx.check(FOLDER_WRITE, to)); // may put it in there * ctx.relink(doc, from, to); * ``` */ relink(child: EntityRef, from: EntityRef, to: EntityRef): void; /** * Archive an entity (#119): hide it from active views and keep it. Only an entity whose * model declares `archive: { permission }` can be archived. * * - **Checks the declared archive key on the entity itself** — unlike `link`, because the * key is declared beside the entity and an operation archiving under another key is the * one mistake this exists to make impossible. A denial throws `PermissionDenied`. * - Only an `active` entity: anything else is `conflict` (`reason: 'invalid_transition'`); * a missing one is `not_found`. * - Stamps `_substrat_archived_at` with the operation's instant and emits one kernel-authored * `entity.archived`, with the actor and the authorization chain on its envelope. * - Transactional with the operation, like every other write. * * Resolves to the state the entity is now in. */ archive(entity: EntityRef): Promise; /** Bring an archived entity back to active. Same key, refusals and recording as `archive`. */ unarchive(entity: EntityRef): Promise; /** * Move an entity to the trash (#119) — a reversible delete. Needs `trash: { permission }` * on the entity, and checks that key on it. An `active` or an `archived` entity may be * trashed; the archive survives the trip, so a `restore` returns it to the archive. * Emits `entity.trashed`. */ trash(entity: EntityRef): Promise; /** * Take an entity out of the trash, back to the state it was trashed from — `archived` if it * was archived, `active` otherwise. Checks the trash key. Emits `entity.restored`, whose * payload's `to` says which — and so does the value it resolves to. */ restore(entity: EntityRef): Promise; /** * The state of one archivable/trashable entity — `active`, `archived` or `trashed` — or * `null` when the row does not exist. What a get-by-id reads before deciding what to answer: * the kernel filters the reads it composes, never a handler's own `SELECT`. * * Checks no permission, like every read on `ctx`. Throws `validation_failed` for an entity * type that declares neither. */ entityState(entity: EntityRef): EntityStateName | null; /** * One page of the TRASH of a declared entity (#119) — `ctx.page` over the trashed rows, * with the declared trash key checked on EACH row inside the kernel, so a handler cannot * forget it. A row the caller may not see in the bin is left out, which can make a page * short; the walk still ends only at a null `nextCursor`. No `total`: a count over rows the * caller cannot see would disclose them. */ pageTrashed(entityType: string, params: Omit): Promise>; /** `ctx.search` over the trash, the declared trash key checked per hit (#119). */ searchTrashed(entityType: string, term: string, options?: Omit): Promise; /** * 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; /** * Capabilities (#1672) — authority carried by a SECRET rather than held by a principal: * "anyone with this link may read this folder until Friday". * * `mint` narrows authority the CALLER holds onto one entity (and what lies beneath it * through declared parent edges), specific keys, an optional operation allowlist, an * optional expiry and use limit, and returns the secret once. Whoever later exchanges * that secret (`ScopeHost.exchangeCapability`) acts as `{ capability: }` — resolved * by the checker against the capability's own row, stamped on every event it emits, * refused into the denial log like any other actor. * * Non-escalating on EVERY use, not only at mint: each key is re-checked here on the * entity with this operation's own check (`ctx.grant`'s predicate), and the checker * re-checks the minter every time the capability acts — so revoking the minter's access * ends what their links can do. Only a principal may mint or revoke; a capability, * connection or schedule cannot delegate. * * ```ts * assertAllowed(await ctx.check('folder:share', folderRef)); * const { secret } = await ctx.capabilities.mint({ * entity: folderRef, * permissions: ['folder:read'], * expiresAt: input.expiresAt, * }); * return { link: `${origin}/#share=${secret}` }; // a FRAGMENT: never sent to a server * ``` * * The kernel stores only the secret's hash, and an idempotency recording withholds the * secret. Beyond that, a TRIPWIRE: while this invocation runs, `ctx.emit`, * `ctx.requestPlatform` and `ctx.sql` refuse any record that carries the secret verbatim, * which catches a module persisting it by accident. It is not a boundary against a module * that means to leak it (`assertNoSecret` says why). Transactional with the operation, * like `ctx.grant`. */ readonly capabilities: CapabilityVerbs; /** * 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 * (vertical-host's `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; /** * Called after the operation COMMITS, with the events it emitted itself (#1746). * * This is the per-request record's "what did it touch" half: the invocation log line * carries the event types and entity refs, so a reader can facet requests by entity * without joining the spine. Only the operation's OWN emits count. Events its * consumers emitted afterwards belong to the same invocation id on the spine, but they * are the consumers' work, and a line that named them would say one request touched * entities its handler never saw. * * Never called for a rolled-back operation, for a read-only session, or for an * idempotent replay: none of them committed an event. An operation that emitted * nothing gets `{ events: [], total: 0 }`, which is a fact, where no call at all * means "not recorded" (an older scope host, or a path that does not report). * * `events` is capped at `EMITTED_REPORT_CAP` in emission order; `total` is the * uncapped count, so a reader can tell a short list from a truncated one. */ readonly onEmitted?: (report: EmittedReport) => void; /** * Called after the operation COMMITS and its executors ran inline (#1184), with what each * delivery of this call's events did. The inline path is the common case of K-22 §4.2, * so the request holder is the one who can tell a person their accept was refused rather * than report success for an effect that never happened. * * Every delivery the call's post-commit tail attempted, which can include an earlier * call's retry that came due: a caller picks out its own by `eventType` and `entity`. * Never called for a rolled-back operation, a read-only session or an * idempotent replay. Absent from a host that predates it — read that as "not reported". */ readonly onExecutorOutcomes?: (outcomes: readonly ExecutorOutcome[]) => void; } /** How many of an invocation's own events `onEmitted` names (#1746). `total` is uncapped. */ export declare const EMITTED_REPORT_CAP = 20; /** One event an operation emitted, as `onEmitted` reports it (#1746). */ export interface EmittedEvent { readonly type: string; /** The entity, as `:`. */ readonly entity: string; } /** What `InvokeOptions.onEmitted` receives (#1746). */ export interface EmittedReport { readonly events: readonly EmittedEvent[]; readonly total: number; } /** The capability stub — the ONLY way code outside the scope reaches it. */ export interface ScopeStub { readonly tenantId: TenantId; readonly scopeId: ScopeId; /** * Which KIND of subject this stub acts as (#1746): a principal, a connection, a * schedule (`system`), a capability holder or a peer vertical. The door that minted * the stub decides it, so it is known before any invoke and on the failure path too, * which is what lets the invocation log facet a refused request by who asked. * * Optional because a stub is an interface a vertical may implement or wrap itself. An * absent kind reads as unrecorded, never as a principal. */ readonly subjectKind?: CheckSubject['kind']; 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; /** * Fired after an invoke through this stub COMMITS having added outbox rows of a type this * deployment exports to other verticals (#1705), with how many. Its own emits and those of * the in-scope consumers that ran in its post-commit tail both count. A vertical's request * handler flags its response `x-substrat-exported-events` (`EXPORTED_EVENTS_HEADER`), and * the router's drain kick then also runs this producer's outgoing edges, so delivery takes * seconds instead of one sweep interval. Never fired for a rolled-back operation, or by a * deployment that exports nothing. Purely advisory, like `onPlatformRequests`. */ onExportedEvents?: (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. */ /** * #2185: what a provision or reconcile reports about the scope it ran in, recorded beside its * receipt (`HostAdmin.markScopeProvisioned`). An object so a later report joins it in the same * write rather than as another call. */ export interface ReconcileOutcome { /** * Every retirement the reconciled version declares finished in the scope: the deployment * answered that it left none open, or the version retires nothing. Absent reads as not finished. */ retirementsFinished?: boolean; /** * #2184: the types of the reconciled version's bootstrap shapes with a table holder that the * scope has ever bound through a link (its sticky `shape-bound` records), sorted. Absent reads as * unknown: every such shape of the version counts as bound. */ boundShapes?: string[]; } 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; /** * A handler for an event ANOTHER vertical of the same tenant exported (#1705). It is registered * under `ModuleRegistration.imports`, keyed by the producing vertical's slug and the event type, * and declared in the manifest as `events.consumes: [{ from, type, schemaVersion }]`. * * It runs in this scope's own transaction, one per (event, module), with its delivery journal * row, exactly as a consumer does. So a delivery is at-least-once across the edge and its * effect happens once. The event is the crossed fact (`ImportedEvent`): the payload and the * entity, never the producer's actor or authorization record. `event.source` says which * vertical and scope it came from. That is data, never authority. * * Parse the payload with your OWN schema. The producer's types are another team's deployed * code, and the version you declared is the only one you will be handed. */ export type ImportHandler = (ctx: OperationContext, event: ImportedEvent) => void | Promise; /** * One membership change for `HostAdmin.applyMembership` (#1184): a TENANT-level role assigned * or taken away and, when `orgId` is given (#2047), the org joined or left in the same unit. */ export type MembershipChange = { tenantId: TenantId; principal: PrincipalId; /** The tenant-level role assigned or taken away. */ roleKey: string; /** * Whose authority bounds the write (§5.1) — the inviter of an add, the remover of a removal: * the unit applies nothing unless this principal covers every permission `roleKey` carries, * read inside the unit (`tenantCoverage`). */ boundedBy: PrincipalId; /** * The org joined (an add) or left (a removal) beside the role (#2047). Bounded by membership: * the unit applies nothing unless `boundedBy` is a live member of it, read inside the unit * (`liveOrgMembership`). A member holds everything the org confers — its grants in each * scope's own store included — so that is the whole bound, and no scope is read. A join's * membership expires no later than `boundedBy`'s own (`joinedMembershipExpiry`). Absent, the * change is the role alone. */ orgId?: OrgId; } & ({ op: 'add'; /** Apply nothing when `principal`'s removal fence stands at or after this instant. */ unlessRemovedSince: Instant; } | { op: 'remove'; }); /** * What the unit did: applied, or why not — fenced by a removal (an add only), out of the role's * bound, or out of the org's (#2047: `boundedBy` is no live member of it). A removal that * applied says whether it took anything (`changed`); one that took nothing still raised the * fence. */ export type MembershipChangeResult = { applied: true; changed?: boolean; } | { applied: false; removedAt: string; } | { applied: false; missing: PermissionKey[]; } | { applied: false; unknownRole: string; } | { applied: false; unknownOrg: OrgId; } | { applied: false; notMember: OrgId; }; /** * 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 through the `admin` * it is handed are stamped with the causing event's id (`causedBy`), so the split trail * joins. That `admin` is bound to this one event, never the host's own (#2055). A handler * that writes for a person attributes from it — `admin.attributed(onBehalfOf)` — which keeps * the event (#2069); a view of the HOST knows nothing of the event. * * A handler that decides an event must never be effected RETURNS `refuseDelivery(reason)` * (#1184). The delivery is journaled terminal with the reason, never retried. A return * value rather than a thrown error, so module code cannot produce one: the most an * operation can do is throw, and a throw is retried like any other failure. */ export type ExecutorHandler = (admin: HostAdmin, event: DomainEvent, scope: ExecutorScope) => void | DeliveryRefusal | Promise; /** * The scope an executor's event came from, as the executor may read it (#1184). * * Here rather than through `HostAdmin` or the host, and the pure adapter is why: its * handler runs INSIDE the scope's actor task, and every host read of a scope re-enqueues * on that actor, so `host.canAssign` from a handler waits on the task holding it and never * returns. The adapter builds this to suit where the handler runs, as it builds a * connector's `openAttachment`. * * Reads only, and checks no permission: the caller is host code that already holds * platform authority. Both answer about the event's own (tenant, scope), never another. */ export interface ExecutorScope { /** One entity's history in this scope — `readHistory`'s answer, oldest first. */ history(entity: EntityRef, page?: ListPage): Promise>; /** * May `principal` confer `roleKey` at this scope's node, or at the tenant node * (`level: 'tenant'`)? The K-21 set comparison `ctx.canAssign` answers, narrowing-aware, * over the tenant's projected role. Throws `unknownRoleError` for a role this tenant does * not define. */ covers(principal: PrincipalId, roleKey: string, level: 'scope' | 'tenant'): Promise; } /** * What one executor delivery did inside the call that emitted its event (#1184) — what * `InvokeOptions.onExecutorOutcomes` reports. `refused` is terminal by the handler's own * decision; `dead-lettered` is terminal by exhaustion; `retrying` will be tried again. */ export interface ExecutorOutcome { readonly executorId: string; readonly eventId: string; readonly eventType: string; /** The event's entity, as `:` — how a caller finds its own event. */ readonly entity: string; readonly outcome: 'delivered' | 'retrying' | 'dead-lettered' | 'refused' | 'inert' | 'routed'; /** The refusal reason, or the failure's message. Absent on a delivery. */ readonly error?: string; } /** One delivery's `ExecutorOutcome` — the one builder both adapters report through. */ export declare function executorOutcomeOf(executorId: string, event: DomainEvent, outcome: ExecutorOutcome['outcome'], error?: unknown): ExecutorOutcome; /** * 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; /** * Deliveries journaled as not executed because the scope is not primary — a fork, a * snapshot or a preview, which cause no outbound effects (#2005, `INERT_SCOPE_REASON`). * Terminal and counted apart from `deadLettered`: nothing failed, the platform declined. */ inert?: number; /** * The scope's lifecycle holds its work (#1713): a suspended or archived scope, or one under a * suspended or deleting tenant, as a CP-less host reads it from the scope's own storage. * Nothing was attempted and every due delivery is still due, so the first pass after the scope * is live again delivers it. Absent otherwise. */ lifecycleHeld?: true; } /** * 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). * * A module switched off on the scope (#1666, `revokeFromSystem`) reports every schedule * `skipped` with `switchedOff: true`, due or not: nothing ran, and that was decided, so * it is neither a failure nor an absence. `skipped` rather than a new outcome because * the durable sweep record parses outcomes on the platform side, and a vertical newer * than its control plane must not emit a value that refuses the whole batch. */ export interface ScheduleRunReport { fired: number; skipped: number; failed: number; /** * The module is switched off on this scope (#1666) — every `skipped` above is that, * not a cadence window. Optional and absent otherwise, so a stored or pre-widening * report stays valid. A module a PITR rewind holds off until its switch is back in the * scope (#1819, Cloudflare only) reports the same. The system door refuses that module's * other calls too, a job run's among them (#1834). */ switchedOff?: true; /** * The scope's lifecycle holds its work (#1713, a CP-less host reading the scope's own * storage): every schedule is `skipped` and no cadence row moved, so a schedule that came * due meanwhile fires once on the first pass after the scope is live again. Absent otherwise. */ lifecycleHeld?: true; /** * Per-schedule failures on this scope: the operation name and the error. One entry is * not a schedule's: `operation: 'switch-hold'` says the rewind hold (#1819) could not be * read. The pass applied only holds it had already read, and its message says which case * it was. Nothing else about the pass changes. */ 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[]; /** * The module's operations: its handlers and, derived from their declaration, the input schemas * the host parses with, the `If-Match` concurrency map and the idempotency opt-outs (#1835). * From `operationsFor(declaration)(handlers)` or, for a module with no declared surface, * `undeclaredOperations(reason, handlers)` — never an object literal: * * ```ts * ...operationsFor(rallyOperations)({ 'rally/book': bookOp, … }), * ``` * * **This is where "parse, don't trust" is kept, rather than in 85 handlers** (#893). * `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. The schemas, the * concurrency the host compares between `BEGIN` and the guards (#129) and the opt-outs (#116) * used to be three more optional fields beside the handlers, so a module could hand over the * handlers and forget the rest, or pair them with another declaration's. One value closes both. * * A schema or a concurrency entry naming no bound operation is an error: it enforces nothing * and reads as coverage. The inputs also carry the declared surface (#119) the host derives * its trash refusal from, so a module with a trashable entity must bind through `operationsFor`. */ operations?: BoundOperations; /** * 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; /** * Handlers for events other verticals export (#1705): producer slug → event type → handler. * Each (slug, type) must be declared in `manifest.events.consumes` WITH `from: slug`. * * A separate map from `consumers` on purpose. A type can arrive both ways, for example two * verticals composing the same engine, and the two are different deliveries with different * authority. The separation is also what keeps a host that predates #1705 safe: it never * reads this map, so it cannot wire an import in as a local consumer of the same type name. */ imports?: Record>; /** * 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; /** * This module's own step in a subject erasure (#2068), for the rows its declared * `erasure: { subjects }` cannot reach — a link the row does not hold itself. Run by * `shredSubject` inside the erasure's one transaction, after the declared entities and * before the key is destroyed; a throw refuses the whole erasure and rolls it back. * Synchronous, idempotent, and handed a `sql` that reaches the module's own tables only * (`module-erasure.ts`). Requires `manifest.erasure`, whose `tables` is that reach. */ onSubjectErased?: OnSubjectErased; } /** * 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 { /** * This same admin, with every row written through it also naming `onBehalfOf` (#977, #2069), * and keeping whatever this one already carries — for the `admin` an executor handler is * handed, the event (`causedBy`). A handler that writes for a person attributes from that * `admin`, so the cause comes along by construction and there is no option to forget. * * Optional so an admin that predates it still satisfies the interface; a caller that finds it * absent writes through the admin it holds, unattributed to a person. */ attributed?(onBehalfOf: OnBehalfOf): 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). * * At the TENANT node it also raises the principal's removal fence (#1184), in the same * unit as the revoke and whether or not anything was held, so a membership-executor add * still on its way for them is refused. The no-op stays unaudited. */ unassignRole(actor: PlatformActorId, assignment: RoleAssignment): Promise; grant(actor: PlatformActorId, grant: CapabilityGrant): Promise; /** * Give a person a vertical's declared entity-grant SHAPE on one entity (#2071): every key in * `permissions`, plus the marker that makes them a holder of the shape, so a key the shape * gains later reaches them at the next {@link reconcileEntityGrantShapes}. What a vertical * calls where it used to `grant` its `ENTITY_GRANTS` keys one at a time. It brings back a key * or marker a revoke tombstoned, as `grant` does, and leaves a live row as it is, expiry * included, as `ctx.grant` does. Audited as `grantEntityShape`. */ grantEntityShape(actor: PlatformActorId, grant: { principalId: PrincipalId; node: { tenantId: TenantId; scopeId: ScopeId; }; entity: EntityRef; permissions: readonly PermissionKey[]; grantedBy: PrincipalId; }): Promise; /** * Top every holder of each declared shape up to the shape as it is now (#2071): a key the * shape gained is granted to each person holding the shape's marker on an entity of that * type. Never a key revoked from that person there (its K-21 tombstone stays). A key merely * dropped from the shape is left in place; a key the shape declares `retired` (#2082) is taken * back from every holder instead, its row tombstoned, once per scope — including a direct grant * of that key to the holder on the same entity, which is the same row. Until it is done on a scope, a * shape that declares a `holder` is also backfilled: each person its holder relationship names * as the owner of an entity (`'self'`, or the vertical's own column) is marked if they hold a * key of the shape there. Never inferred from key sets, so a grant delegated on someone else's * record is never marked. Without a `holder`, there is no backfill. * * Only shapes declared `bootstrap: true` are reconciled — those given with * {@link grantEntityShape} on a person's own record. A SHARING shape, reached through * `ctx.grant` (todo's `list`), is skipped whole: reconciling one would mark every person * something was fully shared with as a holder. * * Bounded: `batch` rows of work (default 500, an integer from 1 to 5000, or `validation_failed`) * per scope transaction, backfill included, and at most ten rows read per row of work: markers * on their own index (#2083), and the backfill's candidates in an index order (#2154; a table * holder's `idColumn` must lead an index for that, and a scope where it does not writes a * `shape-backfill-unbounded` warn line). Each pass resumes where the last one stopped, until one finds * nothing left, so it is safe on a large scope whether or not anything changed, and finishes on * a re-run if interrupted. A holder an older deployment grants the shape to while it runs, behind * the point it has reached: if they lack a key the shape gained, the next reconcile tops them up. * If they hold a key the shape retired, a confirming walk from the first marker, which every * retirement runs before it records itself finished, takes it back. Only a grant landing behind * that confirming walk keeps the key, as one landing after the retirement finished does. Each (person, entity) topped up is * an `entity.grants-topped-up` event on the entity, and each one retired keys were taken from an * `entity.grants-retired` event. Audited as `reconcileEntityGrantShapes` when it changed * anything. Returns how many (person, entity) it topped up, and how many it retired keys from; * plus, only when nonzero, `retirementsLeftOpen`: shapes whose retirement kept meeting new * grants of a retired key (`SHAPE_RETIRE_CONFIRMS_MAX` confirming walks that took keys) and was * left for the next reconcile to run again, unrecorded. */ reconcileEntityGrantShapes(actor: PlatformActorId, node: { tenantId: TenantId; scopeId: ScopeId; }, shapes: readonly EntityGrantShape[], opts?: { batch?: number; }): Promise<{ toppedUp: number; retired: number; retirementsLeftOpen?: number; }>; /** 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`. * * **Refused while the module's schedule kill switch is off on that scope** (#1666), * with `conflict`: restore is the lever; a grant is not. A grant that went through would * hand the module's system authority back to a job run or a `getSystemScope` invoke * while its schedules stayed off. Only `restoreToSystem` turns the switch back on. * A TENANT-level grant (`node.scopeId` null) is refused the same way while the directory * records the module off on any scope of the tenant (#1743). One that already existed when * a scope was switched off gives the module nothing there: the evaluator denies a * switched-off subject on its scope whatever it holds (#1823). */ grantToSystem(actor: PlatformActorId, grant: SystemGrant): Promise; /** * The schedule kill switch (#1666): turn ONE module's scheduled work off on ONE scope. * * Module-wide on the scope, by design — see `systemSwitch` for why a per-permission or * per-schedule switch is either inexpressible or noisy. It tombstones every live * `system:` grant the scope holds (K-21) and makes the scope's OFF marker live * (`system-switch.ts`). While it is off: * * - `runDueSchedules` fires nothing for the module and reports each schedule `skipped` * with `switchedOff: true` — never `failed`, so a switched-off scope makes no noise, * and its cadence clock is untouched, so a due schedule fires on the first pass after * `restoreToSystem`; * - anything acting with the module's system authority is denied by its own * `ctx.check` — a resumable job run (#1577) fails its step, a `getSystemScope` invoke * is refused — including authority from a TENANT-level grant, which OFF cannot * tombstone: the evaluator reads the marker before any grant (#1823); * - and nothing can hand that authority back: `grantToSystem` refuses, and a reconcile * seats no `system:` grant for the module, not even one a newer version declares. * `restoreToSystem` is the only way back. * * **Audited first, on every attempt**: an intent row (with the `reason`) before anything * moves, then an outcome row (`applied`, `refused`, or `failed`), paired by the * `operationId` the call answers with. A repeat — including the retry after a crash * between the move and its outcome row — answers `changed: false` and is audited all the * same. The scope's storage and the admin log are separate stores, so the pair is not * atomic: this order fails toward "an intent with no outcome", never toward "a switch * that moved with no row". * * Throws `not_found` when the module has no authority reaching the scope at all — no * `system:` grant on the scope and no live tenant-level one (#1823) — so a typo in * an emergency does not answer "done". A module whose only authority there is a * tenant-level grant IS switched: OFF writes the marker and tombstones nothing. * * The directory's record of the switch is written BEFORE the scope moves and taken back if * the move throws or holds nothing (#1823), so #1743's tenant-level refusal has no window to * miss. On a host that delegates to the deployment * serving the scope, the write happens THERE and the audit rows HERE. */ revokeFromSystem(actor: PlatformActorId, input: SystemSwitch): Promise; /** * The inverse of `revokeFromSystem` (#1666), and the ONLY way to turn a switched-off * module's schedules back on: tombstones the OFF marker and restores exactly the grants * the OFF took — a grant revoked independently before the switch was pulled stays * revoked, so ON never widens the system principal. Same shape, same `reason`, same * audit-first rows, same idempotence and `not_found`. */ restoreToSystem(actor: PlatformActorId, input: SystemSwitch): Promise; /** * The status read (#1674): every module this scope holds or has ever held system * authority for, and where each stands — `on`, `off`, or `ungranted`, from the SAME * `systemScheduleState` predicate `runDueSchedules` gates on and `revokeFromSystem` * writes against, so the read and the runner cannot disagree. * * While a module is `off`, the entry names who switched it off, when, and why — the * admin log's `intent` row for the `revokeFromSystem` that is still in force, joined by * moduleId. That join happens HERE, never on the deployment serving a hosted scope: the * admin log is the control plane's own store, and a vertical's own deployment holds * none of it — see `SystemSwitchDelegation.status`, which answers the bare position only. * * Delegated for a hosted scope exactly as `revokeFromSystem`/`restoreToSystem` are: a * deployment built before #1666's route answers the same "redeploy the vertical", never * a wrong `on`. */ systemGrantsStatus(actor: PlatformActorId, node: { tenantId: TenantId; scopeId: ScopeId; }): Promise; /** * The fleet read (#1674): the directory's record of the schedule switch, across every * scope. `revokeFromSystem` and `restoreToSystem` write it (`system-switch-record.ts`), * so "which scopes have a module switched off" is one directory read, not a walk of every * scope's store. Unset `position` means both. Access-logged (K-24). * * It is the RECORD, not the gate: the scope's own marker is what the runner reads, and * `systemGrantsStatus` reports both side by side (`schedules` and `recorded`). */ listSystemSwitches(actor: PlatformActorId, filter?: SystemSwitchRecordFilter): Promise; /** * Which of these modules the tenant holds a live TENANT-level `system:` grant for (#1823). * A module whose only authority on a scope is such a grant has nothing in the scope's * storage, so a switch carried into a deployment (a hosted provision, reconcile or restore) * names these as `tenantHeld`, and the deployment's in-unit OFF holds them too. Order follows * `moduleIds`. Access-logged (K-24), as the fleet read is. */ tenantHeldSystemModules(actor: PlatformActorId, tenantId: TenantId, moduleIds: readonly ModuleId[]): Promise; /** * The peer half of a carry (#2029): the peers the directory records OFF on this one scope * (`revokeFromPeer` writes the record, as `revokeFromSystem` writes the module's), and of those * the ones the tenant holds a live tenant-level `vertical:` grant for (#2030). Carried into a * deployment's provision, reconcile or restore beside the modules, so its in-unit OFF puts them * back off too. Empty lists when none is. Access-logged (K-24), as the module reads are. */ peerSwitchCarry(actor: PlatformActorId, node: { tenantId: TenantId; scopeId: ScopeId; }): Promise<{ switchedOffPeers: string[]; tenantHeldPeers: string[]; /** #2045: each recorded-off peer's fence (the record's operation id), by slug. */ fences: Record; }>; /** * Put the directory's OFF positions back into one scope (#1674), for a scope whose storage * lost them: a wipe then re-provision, or a restore of a dump taken before the switch was * pulled. Every module the record holds `off` on the scope is switched off again, through * the same switch `revokeFromSystem` moves (delegated for a hosted scope exactly as that * is). A module whose marker is already live answers `changed: false` and writes nothing, * and one the scope does not hold at all (`held: false`) is left alone. * * Called AFTER provisioning's seat, never before: the seat recreates the grants a wipe * lost, and OFF then tombstones exactly those and records them, so a later * `restoreToSystem` gives them back. A record never turns a module ON. * * Audited as `reassertSystemSwitch`, only when something changed. Throws when a switch * could not be reached, so a caller that records a receipt (the sweep's reconcile) does * not record one for a scope left on. * * `appliedInUnit` (#1742): what the deployment already switched off inside the reconcile's * own unit. Each one that moved, on a module this record holds `off`, is audited here as an * `inUnit` re-assert, since the switch below then answers `changed: false` for it. The * switch still runs for every recorded module: this is the fallback for a deployment built * before the field, and it is idempotent. * * A reported move on a module the record now holds `on` came from a stale list: an * operator's `restoreToSystem` landed between the platform reading the record and the * deployment applying it. That move is undone (switched back on) and audited as * `staleCarry`, BEFORE the record is read again for the OFF pass. Only a module recorded * `on` is ever switched on here. * * Every kind of switch (#2029): after the modules, every peer the record holds `off` on the * scope is switched off again the same way (through `revokeFromPeer`'s switch), audited as * `reassertPeerSwitch`, and answered as an entry naming `vertical` rather than `moduleId`. */ reassertSystemSwitches(actor: PlatformActorId, node: { tenantId: TenantId; scopeId: ScopeId; }, opts?: SystemSwitchReassertOptions): Promise; /** * The PEER kill switch (#1706): turn one calling vertical off on one scope — the tenant's lever * over "another app may call this one", pulled without a push. The mirror of * `revokeFromSystem`, and the same statement underneath (`switchSubjectGrants`): every grant * `vertical:` holds on the scope is tombstoned, an OFF marker goes live, and from the next * call on the door refuses the peer `forbidden` and every check it could reach answers no. No * re-provision re-seats a switched-off peer's grants — `restoreToPeer` is the only way back. * * Audited as `revokeFromPeer` with the required `reason`, intent row first. `not_found` when the * scope holds neither a grant nor a marker for that peer, with nothing written: a typo'd slug * must not plant a marker that silently blocks the peer the day a version declaring it lands. * * A scope served by a vertical's own deployment is switched THERE, over `PeerSwitchDelegation` * — that deployment holds the grants, and the shared control plane's own namespace holds a * placeholder a peer call never consults. A host with no delegation configured refuses such a * scope rather than writing into a DO in the wrong namespace, as `mintCapability` does. */ revokeFromPeer(actor: PlatformActorId, input: PeerSwitch): Promise; /** The inverse of `revokeFromPeer` (#1706): gives back exactly the grants OFF took, never more. */ restoreToPeer(actor: PlatformActorId, input: PeerSwitch): Promise; /** * The peer switch's status read (#1706) — every peer this scope holds or has held grants for, * and where each stands: `on`, `off`, or `ungranted`, from the SAME predicate `admitPeer` * refuses on, so what a tenant reads and what the next call gets cannot disagree. * * While a peer is `off`, the entry names who switched it off, when and why — the admin log's * `intent` row for the `revokeFromPeer` still in force. That join happens HERE and nowhere * else: the admin log is the control plane's own store, and a vertical's own deployment holds * none of it, which is why `PeerSwitchDelegation` answers the bare position only. * * A peer the scope holds no row for is absent rather than reported: "not installed in this * tenant" is the directory's fact, not this scope's, and a surface that shows both joins them. */ peerGrantsStatus(actor: PlatformActorId, node: { tenantId: TenantId; scopeId: ScopeId; }): Promise; /** * Mint a `become` capability on a scope (#1672): whoever exchanges its secret yields * `input.principal` rather than a session — the shape an owner claim link is. The secret is * returned once; the scope keeps its hash. Expiry and a use limit are required. * * `become` is impersonation by another name, so module code never mints one. This is the * platform's mint; a principal mints one only through an adapter's bounded host verb (a * member invite, `becomeMintCheck`, #1686). Audited in the admin log (never the secret, never * its hash). Refuses an unknown or inactive scope, as `getScope` does. */ mintCapability(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, input: BecomeCapabilityInput): Promise; /** * Revoke any capability on a scope (#1672) — the operator's lever for a leaked link, and * how a re-minted claim link retires the last one. Takes effect on the next check, for * every session it handed out. Idempotent; `not_found` for a capability the scope does * not hold. Audited in the admin log. */ revokeCapability(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, capabilityId: CapabilityId): Promise; grantToOrg(actor: PlatformActorId, orgId: OrgId, permission: PermissionKey, node: Node, entity?: EntityRef): Promise; /** * Make `principal` a member of the org. `opts.expiresAt` (#2047) makes the membership lapse * then, as any expiring tuple does; absent, it never lapses. Re-adding replaces the row, * clearing a tombstone and setting the expiry anew. */ addMember(actor: PlatformActorId, tenantId: TenantId, principal: PrincipalId, orgId: OrgId, opts?: { expiresAt?: Instant; }): Promise; /** * One membership change, applied as ONE directory unit (#1184): a single SQLite transaction or * a single synchronous ControlPlaneDO method, with no await inside it. In that unit it * re-evaluates the bound — `boundedBy` must still cover every permission `roleKey` carries, * against the directory as it stands (`tenantCoverage`) — and only then writes. An ADD also * reads `principal`'s removal fence first, then assigns the TENANT-level role and writes its * audit row. A REMOVE unassigns it and raises the fence, as a tenant-level `unassignRole` * does. A removal, a grant, a role redefinition or a demotion lands wholly before the unit * (and governs it) or wholly after it, never between its check and its write. * * With `orgId` (#2047) the org is joined or left in the same unit, and bounded there too: * `boundedBy` must be a live member of it. An add writes the membership — expiring no later * than `boundedBy`'s own — with an `addMember` audit row; a removal tombstones it, with a * `removeMember` row if it took anything. The role and the org apply together or not at all. * * The fence is `_substrat_membership_fences`: every tenant-level `unassignRole` and every * `removeMember` raises it for the person, in the same unit as its revoke — a no-op included, * since a removal of someone whose add is still on its way must still win. */ applyMembership(actor: PlatformActorId, change: MembershipChange): 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. * * Raises the principal's removal fence (#1184) in the same unit as the revoke, a no-op * included, so a membership-executor add still on its way for them is refused. */ 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. * * **#1705 PR 3: refuses a promotion that breaks an installed consumer** unless it is * acknowledged (`exportBreak`). A break is an exported (type, schemaVersion) the outgoing * version promised and the incoming one drops or re-versions, which a running consumer imports * (`exportBreaksOf`). The refusal counts what breaks and names no tenant, since this layer does * not know who is asking. `promotionImpact` is the listing, for a caller that may see it. */ promoteVersion(actor: PlatformActorId, verticalSlug: string, channel: ChannelName, versionId: string, acknowledge?: PromotionAcknowledgement): Promise; /** * Which installed consumers promoting `versionId` to `channel` would break (#1705 PR 3): the * `exportBreaksOf` answer the promote gate refuses on, as a read. Empty for a first promote, * and when no export changed. Access-logged: it reads which tenants run what. A route that * shows it to a confined caller narrows it to that caller's own tenant. */ promotionImpact(actor: PlatformActorId, verticalSlug: string, channel: ChannelName, versionId: string): 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. * * `opts.expectedVersionId` (#1722) makes the pointer update compare-and-set. `null` * expects an unbound scope; omitting it keeps an unconditional bind. A changed binding * is refused with `precondition_failed`, leaving its version and vertical untouched. * * **#1756: refuses a bind that breaks an app in the scope's own tenant** unless * `opts.acknowledge.exportBreak` is set — the promote gate's question * (`bindExportBreaksOf`), asked of one install. Judged on what the scope RUNS before and * after, so re-pointing a scope that runs its vertical's serving script, a fork, a preview, * or a first bind is never refused. `bindingImpact` is the listing. */ bindScopeVersion(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, versionId: string, opts?: { snapshot?: boolean; acknowledge?: BindAcknowledgement; expectedVersionId?: string | null; expectedErasureEpoch?: number; /** #1722: the copy move this bind routes the scope onto, confirmed in the same write. */ confirmMove?: ScopeCopyMoveConfirmation; }): Promise; /** * Which apps in the scope's tenant binding `versionId` would break (#1756): the answer * `bindScopeVersion` refuses on, as a read, so a route can ask it BEFORE it moves any data * and show the caller what an acknowledgement would break. `opts.servingRef` asks about the * route moving in the same act (an adopt: onto the serving script, then bound to its * version). Refuses what the bind would refuse first — an unknown scope or version, or one * not admitted — so a caller is never asked to acknowledge a bind that cannot happen. * Access-logged. */ bindingImpact(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, versionId: string, opts?: { servingRef?: string | null; }): 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. * * `null` clears it back to "unknown", which the sweep reconciles on its next pass. That * is for something that undid what a provision did — a PITR rewind (#1674) takes the * scope's grants and its schedule switch back to the bookmark, and (#2185) a restore of a backup * or a kept copy loads data that may predate a reconcile, a finished retirement included. * * #2185: `outcome.retirementsFinished` is whether that reconcile finished every retirement * the version declares (`retiredKeysOf`), and writes `retirementsFinishedVersionId` in the same * write: `versionId` when true, null otherwise, absent included. So a caller that cannot say * (a provision with no shapes, a reconcile answered by a host that predates the report) leaves the * install judged as still holding the keys its version retires, and the sweep reconciles it again. * * #2184: `outcome.boundShapes` likewise writes `boundShapes` in the same write, paired with * `versionId` — null when `versionId` is null or the outcome does not say, which the gate reads * as unknown and the sweep reconciles again. */ markScopeProvisioned(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, versionId: string | null, outcome?: ReconcileOutcome): 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; /** * One version's SQL migrations (#1764), in the order the host runs them. They are stored * apart from the manifest, which `versionManifest` returns without them. Null for a version * that carries none that can be shown (pushed before #1677, or over the push's caps), which * a reader must show as "not available", never as "no migrations". `[]` is a version whose * modules ship none. Refuses a version of another vertical, like `versionManifest`. * Access-logged: an owner read for the promote review, never a bulk or background path. */ versionMigrations(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. * * **#1756: refuses a move that breaks an app in the scope's own tenant** unless * `opts.acknowledge.exportBreak` is set, as `bindScopeVersion` does. Moving the route is * the other half of what a scope runs: onto the serving script, a scope goes from its own * version to the served one before its pointer ever moves, so this is where an adopt is * judged. `bindingImpact` with `servingRef` is the listing. */ setScopeServingRef(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, servingRef: string | null, opts?: { acknowledge?: BindAcknowledgement; expectedErasureEpoch?: number; confirmMove?: ScopeCopyMoveConfirmation; }): 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; /** * Record a staff resolution of a kept copy (#1722, Codex #2008 r7) in the admin log. A kept * copy is one a carry's wipe refused because it took a write the carry never copied; the * vertical's store holds it, so the resolution itself (a discard, or a restore forward over the * live store) runs there, and this is its record: which script held it, what was done, and the * write revision before and after. The directory changes nothing else. Refuses an unknown scope. */ recordKeptCopyResolution(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, resolution: KeptCopyResolution): Promise; /** A script that holds or held this scope's data. The moveId fences delayed cleanup * from a later rollback and carry through the same script. */ recordScopeScriptCopy(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, scriptRef: string, moveId: string, /** Which end of the move this script is, and for a destination the load stamp its restore * will leave: what a crash-recovery sweep fences its wipe on. The entry is leased to the * recording move for `leaseMs` (default `SCOPE_COPY_LEASE_MS`). */ opts?: { role?: ScopeCopyRole; loadStamp?: string | null; leaseMs?: number; }): Promise; /** * Claim up to `limit` pending entries whose lease ran out before `now` (a move that crashed, * or a sweep that did), leasing each to `owner` until `leaseUntil`. An entry whose lease is * live is never returned, so a move still in flight keeps it; and a move whose lease ran out * can no longer confirm (`confirmMove`), so the claimant cannot lose a race to it. */ claimExpiredScopeScriptCopies(actor: PlatformActorId, input: { now: string; leaseUntil: string; owner: string; limit: number; }): Promise; /** Reserve a scope for script cleanup only when no copy move is in flight. The claim is a * column on the directory row, not a ledger entry: it refuses new moves and the conditional * bind until the row itself is deleted, and a crashed reaper resumes it. */ beginScopeScriptReap(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId): Promise; /** Only a confirmed move makes a source eligible for a nonterminal wipe. */ settleScopeScriptCopy(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, scriptRef: string, moveId: string, state: 'eligible' | 'retained' | 'kept' | 'done', marker?: { loadStamp: string | null; revision: string | null; }, /** A sweep's own claim: the entry settles only while it is still pending under that owner. */ opts?: { claimedBy?: string; }): Promise; /** * Record a copy made before this ledger existed (#1722's backfill) as `retained`: reached by reap * and erasure, never wiped by the sweep. One entry per (scope, script), under `BACKFILL_MOVE_ID`. * Answers `ledgered`, writing nothing, when the ledger already names the script for the scope in * any state; `reaping` under a reap claim; `missing` for an unknown scope. Never throws for those, * so a backfill page reports each one instead of stopping. A recorded entry is audited as * `backfillScopeCopy`. */ backfillScopeScriptCopy(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, scriptRef: string, /** The admin-log row the script was read from, named in the `backfillScopeCopy` row a recorded entry writes. */ opts?: { fromLogId?: string; }): Promise; /** Move a failed retry to the back of the due queue without changing its state. */ touchScopeScriptCopy(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, scriptRef: string, moveId: string): Promise; listScopeScriptCopies(actor: PlatformActorId, filter: { tenantId?: TenantId; scopeId?: ScopeId; state?: 'pending' | 'eligible' | 'retained' | 'kept' | 'done'; limit?: number; after?: { scriptRef: string; moveId: string; }; }): 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[]; }[]>; /** * The size in bytes of a CO-LOCATED scope's database (#1524): `SqlStorage.databaseSize` * on a Durable Object, `page_count × page_size` on SQLite. It covers the scope database * only. Attachment blobs, per-tenant D1 stores and the lake are elsewhere and are not * counted. It wakes the scope, so the one caller is an on-demand storage reading, never * a sweep. For a dispatch vertical the route reads it through the vertical's * `/internal/database-size` instead, and this is the co-located fallback. Access-logged. */ scopeDatabaseSize(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId): Promise; /** * The size of each scope store bound for this scope (#2207). Access-logged like * `scopeDatabaseSize`. An undeclared scope has none, so the list is empty. This is * the co-located reading. A hosted control plane asks the store script directly. */ scopeStoreSizes(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId): Promise<{ binding: string; bytes: number; }[]>; /** * #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 whose correctly paired scope and owning tenant * are both active (#1713). Otherwise returns undefined, without changing the * binding. Active previews and embedded scopes without a deployment still resolve. * This uncached request-path gate does not cover direct/background CP-less calls * or requests already dispatched. CP-backed `getScope` retains its own gate. */ resolveHostname(hostname: string): Promise; /** * "The instance of vertical `vertical` in tenant `tenantId`" (#1706) — how the platform * addresses another app of the same tenant by slug, never by hostname. * * One rule wherever it is asked (`resolveVerticalInstanceFrom`): a primary, active scope of * THAT tenant bound to THAT vertical; `not-installed` for none, `ambiguous` for several — never * a guess. The tenant is the caller's own, supplied by the platform; there is no parameter by * which a caller could name another. Names match exactly: this is the cross-vertical edges' * read. A peer call also reaches the tenant's pinned `/`, through * `resolvePeerInstance` (#2252). * * Takes NO actor and is not logged, for `resolveHostname`'s reason: it is a machine read on the * per-call path, before any staff member is involved. */ resolveVerticalInstance(tenantId: TenantId, vertical: string): Promise; /** Resolve for one caller at execution time. An explicit stale binding refuses without fallback. */ resolvePeerInstance(tenantId: TenantId, callerScopeId: ScopeId, vertical: string): Promise; /** Read the caller's explicit choice, if any. Tenant-scoped and recorded as an admin read. */ peerBinding(actor: PlatformActorId, tenantId: TenantId, callerScopeId: ScopeId, vertical: string): Promise; /** Every explicit target choice this calling scope currently holds. */ peerBindings(actor: PlatformActorId, tenantId: TenantId, callerScopeId: ScopeId): Promise; /** Bind or clear this caller's target. Both endpoints are checked in the directory at write. */ setPeerBinding(actor: PlatformActorId, tenantId: TenantId, callerScopeId: ScopeId, vertical: string, targetScopeId: ScopeId | null): Promise<{ changed: boolean; previous: ScopeId | null; }>; /** * 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. * * A row that does not decode is SKIPPED (#1636): never returned, so no sink receives an * event built from stand-ins, and never stamped, so the stamp stays exact. The read steps * past it so the rows behind it still ship, and says what it stepped over in the answer's * optional `skipped` — absent on a clean read, so every caller that reads an array reads * the one it always did. See `readUndrainedOutbox` for the bound, and for the cost. */ 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; /** * The PRODUCER half of a cross-vertical edge (#1705): the events of the types `input.wants` * after `input.after` that this scope releases to `input.consumer`, oldest first. * * Everything that decides what leaves is decided HERE, by the code running this scope, * never by the caller: * * - a type this scope's modules do not `export` is not read at all. It is reported in * `unexported`, and asking for it releases nothing; * - the consumer's principal (`vertical:`) must hold every read key the request * touches, AT this scope. If one is missing the edge is `paused`: nothing is walked, and * `next` stays `after`, so the backlog waits in this outbox and nothing is lost to the * gap. That is #1666's rule ("restore is the lever") applied to a kill switch on the peer; * - a row classified other than `none`, at another version than the one wanted, past the hop * cap, or undecodable is WITHHELD. It is named without its payload, and the watermark steps * past it, because each of those facts is permanent. * * Reads the COMMITTED outbox (#1624). An in-flight operation's event must not reach another * vertical before it exists. Access-logged like `readUndrainedEvents`: a platform read of * domain data on its way out of the scope. */ readExportedEvents(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, input: ExportReadInput): Promise; /** * The CONSUMER half's first question (#1705): what this scope's running code imports, and * how far it has read each producer. Both come from the scope rather than the registry, * because the version serving it is the one whose handlers will run. */ importState(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId): Promise; /** * The replay lever (#1705 PR 3): move this CONSUMER scope's watermark on the edge from * `move.from`. Replay re-delivers from a point, and skip passes events over. The semantics, * the refusals and why a bare rewind would replay nothing are in `moveImportCursor` * (vertical-events.ts). The acknowledgement literal is part of the input, because a replay * runs each importing handler again, and anything those handlers send or call outside the app * happens again (`REPLAY_EFFECT`). * * The producer is resolved HERE, from the directory, in this scope's tenant, by the sweep's * own rule (`importCursorSourceOf`). The caller names a vertical, never a scope. The consumer * must itself be its vertical's one primary instance, since a fork or a preview is no end of * an edge. Audited as an admin write, with an intent row before the move and its outcome after, * both carrying the act's `replayId`. * * Takes effect on the next pass: a sweep or a kick reads from the new watermark. A pass already * in flight is refused by the batch's compare-and-set (`stale`), so it cannot undo the move. */ moveImportCursor(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, move: ImportCursorMove): 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 entity's declared lifecycle, replayed over the scope's outbox (#1744) — * `readLifecycleFlow`, hoisted. Edge counts, instances per state, time-in-state, the * longest-stuck instances and the funnel, for the process map. The caller passes the * declaration (the running version's `model.json` lifecycle); the scope holds no model. * Reads only the lifecycle field of unclassified payloads; logged like every read here. */ lifecycleFlow(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, input: LifecycleFlowInput): Promise; /** * Calls per `(entityType, operation)` per time bucket over the scope's outbox (#1750) — * `readOperationSeries`, hoisted: the business volumes Pulse draws. The caller passes * the pairs (from the running version's declared lifecycles); the scope holds no model. * Reads no payload; logged like every read here. */ operationSeries(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, input: OperationSeriesInput): 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; /** * #1745: the refusal log — every lifecycle move `assertTransition` refused and the * operation failed with, recorded after the rollback exactly as a denial is. A bounded * page of raw rows, newest first; narrow with `entityType`/`entityId` (which record), * `actor`, `operation`, `invocationId` (which call) and a `since`/`until` window. * * The row read beneath `lifecycleFlow`'s `refused` counts, read like the denial log: * a `PlatformActorId`, a K-24 access-log entry and the K-3 (tenantId, scopeId) * cross-check. Unlike a denial row it names a record and the state it was in — a key * and a status value, never the record's fields. */ listRefusals(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, filter?: RefusalFilter): Promise; /** * The operator's read of a scope's capabilities (#1686): the directory `ctx.capabilities.list` * reads from inside a module, for someone who has no operation to stand in. Newest first, * live (unrevoked) ones only unless `includeRevoked`, narrowable to one `entity`, and * keyset-paged: `limit` 1..200 (default 50) and a `cursor`, the previous page's `nextCursor`. * `nextCursor` is set only when more records follow, so a walk never truncates silently and * a full last page ends it. A malformed cursor is refused. * * **Never a secret, never a hash.** A record carries what the capability may do (mode, entity, * keys, operation allowlist, or the principal a `become` yields), who minted and revoked it, * its expiry, use limit and use count. The kernel's one query (`capabilityListQuery`) does not * select `token_hash`, and the record schema has no field to hold one. * * Read like the denial log: a `PlatformActorId`, a K-24 access-log entry and the K-3 * (tenantId, scopeId) cross-check. Pull-only — it changes nothing, so it is not in the admin * log; `mintCapability` and `revokeCapability` are. */ listCapabilities(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, filter?: CapabilityFilter): 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; /** * provisioning|active|suspended → archived. Stops the active-scope meter (§9). A status * change only: the storage stays until `reapScope`. Records the status it left as * `archivedFromStatus` (#1524), so a scope abandoned while still provisioning, which never * held data, is not metered for storage. */ 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. * * A scope store is reached, by `substrat.erase`, after the scope-side redaction and * before the key is destroyed. A store that answers with an error throws, and the key * stays, so a retry converges. The receipt's `storeErasures` names each binding. */ shredSubject(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, subjectId: string): Promise; /** * Subjects whose key row is tombstoned (`shredded_at` set), in subject-id order. * A page is at most 100. `cursor` is the last id the caller already has. */ listShreddedSubjects(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, cursor?: string): Promise<{ subjects: string[]; nextCursor: string | null; }>; /** Monotone directory fence against a carry from before subject erasure. */ scopeErasureEpoch(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId): Promise; /** Finish an orchestrated erasure only after every script holding a copy has redacted it. * The copy count fences a move that was recorded after the coordinator listed scripts; * ledger rows are append-only, so a changed count means that inventory is stale. */ finalizeSubjectShred(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, subjectId: string, redactions: readonly SubjectRedactionCounts[], expected: { versionId: string | null; servingRef: string | null; epoch: number; copyCount: number; }, /** * What the serving script's stores erased (#2068). The coordinator calls * `substrat.erase` once — store objects are per vertical, not per script copy — * and passes the result. Omitted means this host erases its own stores, which * is the pure adapter's co-located shred. */ storeErasures?: readonly { binding: string; erased: number; }[]): 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. * * `expiresAt`: a date replaces the connection's expiry, `null` clears it (the credential * that had one was replaced by one that has none), and omitted keeps it. */ updateConnectionSecret(actor: PlatformActorId, id: ConnectionId, secret: ConnectionSecret, expiresAt?: string | null, 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; /** * A vertical's MAILED connect link (connections.md §3.5.4) — the row that makes a consent * round single-use and revocable when it lives days rather than minutes. Beside the * connections because it is their front door: the round it starts ends in * `createConnection` (or a rotation) for the same tenant and scope. * * Every verb below is keyed by (tenant, scope): a link of another scope or tenant is * `undefined` / `unknown`, never a refusal that admits it exists. Expiry is judged against * the host's own clock. Mint, revoke, consume and restore are each audited — metadata * only, though nothing here is secret: the credential does not exist until a round is * consumed, and it never touches this row. */ mintConnectLink(actor: PlatformActorId, input: MintConnectLinkInput): Promise; getConnectLink(actor: PlatformActorId, key: ConnectLinkKey): Promise; /** Newest first. `outstandingOnly`: outstanding AND unexpired — the links that still open. */ listConnectLinks(actor: PlatformActorId, filter: ConnectLinkFilter): Promise; /** * Outstanding → revoked. Idempotent: a revoked or used link is answered as it stands and * nothing is written. `undefined` when the scope holds no such link. */ revokeConnectLink(actor: PlatformActorId, key: ConnectLinkKey): Promise; /** * Outstanding and unexpired → used, recording what the consent attached. Atomic: of two * concurrent consumes exactly one answers `ok`. Otherwise the typed reason. */ consumeConnectLink(actor: PlatformActorId, input: ConsumeConnectLinkInput): Promise; /** * Used → outstanding, the best-effort undo after the credential store failed. Only a * used, unexpired link moves; `false` for anything else, a revoked link included. */ restoreConnectLink(actor: PlatformActorId, key: ConnectLinkKey): 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. * * #1691: the settlement may also carry the call's timing (`durationMs`, `status`, * `timedOut` — all optional, so a caller that knows none of them records the line * exactly as before). Each host hands the call, alongside the line, to its * `ConnectorCallRecorder` — one data point per call, fire-and-forget; see * `connector-calls.ts`. */ recordConnectionUse(id: ConnectionId, outcome: ConnectionUseOutcome): 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; /** * Delete ops failures, issues and sweep runs past their retention (#1632), oldest first and * at most `limit` rows of each table per call. It is the scheduled pass's half of the prune * each of those tables already runs on write, so a directory that records nothing new still * sheds them on time; a backlog larger than `limit` drains over passes. Returns what each * table deleted. Not audited, like the prune-on-write it completes: retention-bounded * telemetry, not evidence. Optional so a host that predates it degrades the sweep's phase * to `null`. */ pruneTelemetry?(actor: PlatformActorId, limit: number): Promise; /** * The stored storage gauge (#1524): record the scheduled pass's attempts to read scope * database sizes. Each attempt replaces the scope's attempt row (what the pass picks due * scopes by); a successful one also writes the day's sample, one row per (scope, UTC day), * a later same-day reading replacing an earlier one. A failed one leaves the last sample * standing. Rows are written only for a stored (not provisioning, not reaped) scope the directory holds under the named * tenant. Returns how many SAMPLE rows were written. Not audited, like `recordSweepRun`: * retention-bounded telemetry, not evidence. * * The gauge methods are optional, and the sweep's storage phase is skipped on a host that * lacks them, so an adapter built before #1524 still satisfies `HostAdmin`. */ recordScopeStorage?(actor: PlatformActorId, readings: readonly ScopeStorageReadingInput[]): Promise<{ recorded: number; }>; /** * Stored storage samples (#1524) — the latest per scope, or a bounded history. See * `ScopeStorageFilter`. A directory read that wakes no scope. Access-logged (K-24). */ listScopeStorage?(actor: PlatformActorId, filter?: ScopeStorageFilter): Promise; /** Every stored scope's latest storage-read attempt (#1524): the storage phase's due list. Access-logged. */ listScopeStorageAttempts?(actor: PlatformActorId): Promise; /** * Delete storage samples past `STORAGE_GAUGE_RETENTION_MONTHS`, oldest first and at most * `limit` per call, so a backlog drains over passes. Returns how many went. Not audited. */ pruneScopeStorage?(actor: PlatformActorId, limit: number): 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; /** * Findings (#1748), most recently seen first: the tenant-scoped inbox. `filter.tenantId` * absent is the staff fleet read; every tenant-facing caller passes it, and the HTTP surface * forces it from the principal. Access-logged (K-24). No cursor, for `listIssues`'s reason. */ listFindings(actor: PlatformActorId, filter?: FindingFilter): Promise; /** * A verdict on one of `tenantId`'s findings: acknowledge, resolve, or reopen. Keyed on the * tenant as well as the id, so another tenant's finding reads as unknown (undefined). * Audited with the before/after diff (K-33). */ setFindingStatus(actor: PlatformActorId, tenantId: TenantId, id: string, status: FindingStatusInput): Promise; /** * Suppress with a rule: a scope (kind / operation / code / subject) and an expiry of at most * `FINDING_RULE_MAX_DAYS`. The findings it covers now are suppressed at once; later * occurrences it covers are counted but keep their finding suppressed. Audited, naming how * many findings it suppressed. */ createFindingRule(actor: PlatformActorId, tenantId: TenantId, input: FindingRuleInput): Promise<{ rule: FindingRuleEntry; suppressed: number; }>; /** End one of `tenantId`'s rules now. Undefined for an unknown rule. Audited. */ revokeFindingRule(actor: PlatformActorId, tenantId: TenantId, ruleId: string): Promise; /** * The findings retention pass (#1748) — `pruneFindings`: a quiet open or acked finding is * resolved as `stale` with a `resolveStaleFinding` audit row written in the same unit, never * deleted; closed findings and expired rules past the window are deleted, at most `limit` of * each per call. Optional so a host that predates findings degrades the sweep's phase to `null`. */ pruneFindings?(actor: PlatformActorId, limit: number): Promise; /** `tenantId`'s suppress rules, newest first; `active` keeps the unexpired ones. Access-logged. */ listFindingRules(actor: PlatformActorId, tenantId: TenantId, filter?: { active?: boolean; limit?: number; }): 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; /** * Record one phase of an owner hand-over (#1665) on the admin log, as `transferOwner`. The * hand-over runs in the vertical's deployment, so the control plane writes these around its * call to it: `intent` first (and must not call if this throws), then the outcome. Narrow for * `recordDelegatedRead`'s reason: fixed action, parsed entry, request actor, adapter-stamped * `id` and `at`. Throws when the row cannot be written. */ recordOwnerTransfer(actor: PlatformActorId, entry: OwnerTransferAudit): Promise; /** * Record one change to a scope's copy marker (#2005) on the admin log — `markScopeCopy` for a * `mark`, `clearScopeCopyMark` for a `clear`. The marker lives in the vertical's deployment, so * the control plane writes this around its call there — `recordOwnerTransfer`'s shape: fixed * actions, parsed entry, request actor, adapter-stamped `id` and `at`. Throws when the row * cannot be written. */ recordCopyMark(actor: PlatformActorId, entry: CopyMarkAudit): Promise; /** * Record one phase of a dashboard member change (#1150) — an invite, a role move or a removal * on an installed vertical's scope — as `manageScopeMember`. The change runs in the vertical's * deployment, so the control plane writes these around its call: `intent` first (and must not * call if this throws), then the outcome. `recordOwnerTransfer`'s shape. Throws when the row * cannot be written. */ recordMemberChange(actor: PlatformActorId, entry: MemberChangeAudit): Promise; /** One intent or outcome of a tenant's peer-instance binding change (#1720). */ recordPeerBindingChange(actor: PlatformActorId, entry: PeerBindingAudit): Promise; /** * Close one audited change whose intent has no outcome (#2064): in ONE transaction, read the * intent `intentId` names, and if no outcome row exists for its operation, write an `unknown` * outcome carrying `error` and an ops-failure row (`audit.`, stage `outcome-unknown`) * for the staff digest. Answers `true` when it wrote them, `false` when an outcome was already * there, so concurrent callers settle an operation once. Throws `not_found` for an id that is * not an audited-change intent. See `audit-outcome.ts` for why a later real outcome still wins. */ settleUnrecordedOutcome(actor: PlatformActorId, input: { intentId: string; error: string; }): Promise; /** * Every admin-log row of the given audited operations (#2064), read through the operation-id * index in bounded batches — one statement per `AUDITED_OPERATIONS_BATCH` ids, never a scan of * the log. Only rows of the exact (action, operation, tenant, scope) asked about are returned. * Reading the audit trail is itself recorded, once per call. */ auditedOperations(actor: PlatformActorId, refs: readonly AuditedOperationRef[]): 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; /** * An attachment's integrity witness: the lowercase hex SHA-256 of its bytes (Web Crypto). * One definition for the hash an upload records and every read that holds bytes to it — * both adapters' `open`, and the extraction job (#1575). */ export declare function attachmentSha256(body: Uint8Array): Promise; /** * Who binds an operation, as the §4.3 entitlement gate needs to know it: the owning * module, the SKU flag its manifest declares, and the operations that module declares * as its own recurring schedules (#383). Each adapter records one per bound operation * at `registerModule`, and hands it to {@link requiredEntitlementFor} at every invoke. */ export interface OperationEntitlement { moduleId: string; entitlementKey: string; scheduledOperations: ReadonlySet; } /** * The SKU flag the §4.3 gate demands for ONE invoke — `undefined` when it demands none. * One predicate, run by every place the gate lives (the Cloudflare coordinator, the * ScopeDO it hands the key to, the SQLite stub), so no adapter can be the lenient one. * * **The rule:** a module loads for a tenant only if the tenant holds its SKU flag. An * operation with no recorded binding (a bare `defineOperation`, tests and glue) is ungated. * * **The one exception (#1654): a module's own declared schedule, fired through the * system door.** All three must hold: * 1. the invoke came through the system door (`getSystemScope`, so `systemModuleId` * is set) — a door with no route; no HTTP request, principal, connection or peer * reaches it; * 2. the operation is bound by THAT module (`moduleId === systemModuleId`) — the system * principal of one module never borrows the exception for another module's operation; * 3. the module declares that operation in its own `schedules` — a system-door invoke of * any other operation is still gated, so a job run or a direct `getSystemScope` call * is not widened. * * Why the SKU is not the switch there: a composed engine's schedule runs in the engine's * name, but the engine is loaded into the scope by the VERTICAL that composes it, and a * standard install grants the vertical's keys, not the engine's (meridian composes * `engine-absence` by call; its `absence/expire-stale` was refused on every install). * The schedule's authority is already the `system:` grant — seated only by * provisioning a scope with that module registered, holding exactly the permissions the * schedules declare, and pulled by the kill switch (#1666). "The grant IS the switch" * (#383) now holds for loading as well as for permissions. What does NOT change: every * user-reachable door — `getScope`, connector, capability, peer, impersonation — still * demands the operation's own key, so the engine's operations stay unreachable on an * install that does not hold the engine's SKU. * * The cost, stated: on a tenant whose SKU for a module lapses, that module's own declared * schedules keep running on scopes that still hold the system grant. Stopping them is the * schedule switch (`revokeFromSystem`, #1666), or archiving the scope. */ export declare function requiredEntitlementFor(operation: string, binding: OperationEntitlement | undefined, systemModuleId: string | undefined): string | undefined; /** * 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; /** * Attachments whose EXTRACTED TEXT matches `term` (#1575), newest first, as records the * caller could `open`. * * Authorized BEFORE it matches: the owners the caller may read — the check `open` makes, * the target's `readPermission` on the owning entity — are decided from the scope and the * caller alone, and the match runs over those owners only, so an attachment the caller * cannot open neither appears nor takes a slot, however many there are. No count, no * score, no snippet, and newest-first rather than relevance order: each of those would * describe matches the caller may not see (`attachment-text.ts` says how). The term * grammar and the limit are `ctx.search`'s (`searchMatchExpression`, `searchLimit`); * a term too short to match throws `SearchTermTooShort`. * * A caller without scope-level read on a target type has that type's owners checked one * by one, at most `ATTACHMENT_SEARCH_OWNER_MAX` of them; past that the search throws * `forbidden` with reason `ATTACHMENT_SEARCH_TOO_MANY_OWNERS`, the same for every term. * * Only text that has been extracted matches: an upload is searchable once its * extraction job has run (`readAttachmentText` says where it is). */ search(term: string, options?: AttachmentSearchOptions): Promise; } /** `ScopeAttachments.search`'s options. */ export interface AttachmentSearchOptions { /** Defaults to `DEFAULT_SEARCH_LIMIT`, capped at `MAX_SEARCH_LIMIT`. */ readonly limit?: number; } /** * 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; /** * The drained batch's dedupe key on the directory's `_substrat_sweep_runs` (#1232), as * both adapters build it: `recordSweepRun` is an `INSERT OR IGNORE` against this index, * which is what makes a replayed drain write nothing twice. * * `kind` is in it since #1572, and that is the fix rather than a tidy-up. Every entry of * one drained batch shares its `request_id`, and `unit` is built from two namespaces that * nothing keeps apart — `:` for a schedule, `:` * for a freshness verdict — while `scheduleSpec.operation` is `z.string().min(1)`. A * schedule named exactly like an event type its scope expects freshness on collided on * `(request_id, unit)`, and the ignore-on-conflict write discarded the second row with no * error anywhere. Both rows are signals by their absence (a stopped evaluator, a missed * run), so losing one read as the very thing it was meant to rule out. It is #1288's * collision, in the record table beside the state table. * * Shared rather than spelled in each adapter because a directory that predates it * re-creates it (`ensureSweepRunsIntentKind`, in both), and `lint:spine-ddl` sees only * the created copy. One definition keeps the re-created shape and the created shape the * same shape; the gate then holds each adapter's DDL to including it. */ export declare const SWEEP_RUNS_INTENT_INDEX = "CREATE UNIQUE INDEX IF NOT EXISTS _substrat_sweep_runs_intent ON _substrat_sweep_runs (request_id, kind, unit);"; /** * Whether a directory's `_substrat_sweep_runs_intent` already has `kind` among its * columns, read off the index's `sql` in `sqlite_master` — which DO SQLite serves and * `PRAGMA` does not, so both adapters detect by one strategy. `false` means the index * must be DROPPED and created again: `CREATE … IF NOT EXISTS` matches on the name alone, * so on every existing directory the old `(request_id, unit)` index would otherwise * stand for good, while a fresh one — and any test that starts from one — gets the new. */ export declare function sweepRunsIntentHasKind(indexSql: string): boolean; /** * 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, and on the * scheduled pass (`pruneTelemetry`, #1632), so a quiet directory sheds it on time too. */ export declare const ISSUE_RETENTION_DAYS = 180; /** What one `pruneTelemetry` call deleted, per table. */ export interface TelemetryPruneReport { opsFailures: number; issues: number; sweepRuns: number; } /** * A caller-supplied row bound, checked before it reaches a SQL `LIMIT` (#1632). SQLite reads a * negative `LIMIT` as no limit at all, so `-1` turns a bounded batch into an unbounded one; a * fractional or non-finite value is refused by SQLite itself, as a failure every later call * repeats. Neither is a value anyone means, so it is a configuration error, refused here with the * option's name, and never silently normalized into something the caller did not ask for. * Returns the value, so a call site can bind it in place. */ export declare function assertRowLimit(name: string, value: number): number; /** * A caller-supplied row offset, checked before it reaches a SQL `OFFSET` — `assertRowLimit`'s * sibling. Zero is a valid offset; a negative, fractional or non-finite one is refused rather * than passed to SQLite, which fails the statement on it. */ export declare function assertRowOffset(name: string, value: number): number; /** How many rows of EACH telemetry table one `pruneTelemetry` call deletes, by default. */ export declare const TELEMETRY_PRUNE_BATCH = 500; /** * The three telemetry retentions as statements, for `pruneTelemetry` on both adapters (#1632). * * Each table already prunes on write, and that bound holds only while something is written: * a quiet directory keeps a row past its retention for as long as nothing new arrives in * that table. An issue is the case that matters. Erasure skips an issue whose exemplar owner * is unknown, on the promise that it ages out within `ISSUE_RETENTION_DAYS`, and on a * directory with no new failures nothing ever deleted it. The scheduled pass runs these * statements, which makes the stated retention a bound in time rather than in traffic. * * **Bounded per call, oldest first.** After a long pause or a restore the backlog can be a * year of sweep runs, and one unbounded DELETE over it is a pass that fails on the Durable * Object every tick. Each statement deletes at most `limit` rows, chosen through the table's * own retention index (`… WHERE rowid IN (SELECT rowid … ORDER BY LIMIT ?)`), so the * backlog drains over passes. `RETURNING` makes the count what was actually deleted on both * adapters, rather than a rows-written figure that would also count index writes. */ export declare function telemetryRetentionStatements(nowMs: number, limit: number): { table: keyof TelemetryPruneReport; sql: string; params: [string, number]; }[]; /** * One bounded retention DELETE: at most `LIMIT ?` rows whose `column` is before `?`, oldest * first through the column's own index, `RETURNING 1` so the count is what was deleted. Takes * `(horizon, limit)`. The statement `telemetryRetentionStatements` and `pruneFindings` share. */ export declare function boundedRetentionDelete(table: string, column: string): string; /** 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; /** A `platform-request` row's drain totals (#1840); null on every other kind. */ platformRequests?: PlatformRequestDrainTotalsEntry | 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, kind, unit) with an ignore-on-conflict * write (`SWEEP_RUNS_INTENT_INDEX`), so a replayed drain (a settle that failed in * transport, a partial batch re-run) writes nothing twice — while a schedule and * a freshness entry of one batch that derive the same unit are two rows (#1572). * 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; /** Rounded wall time in milliseconds; null for a migration applied before recording. */ durationMs: number | null; /** SQLite data rows changed by the migration, excluding its journal stamp. */ rowsChanged: number | 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; } /** How long a recorded copy move owns its pending entries before a sweep may settle them (#1722). */ export declare const SCOPE_COPY_LEASE_MS: number; /** Which end of a copy move a script is (#1722). Null on an entry recorded without one. */ export type ScopeCopyRole = 'source' | 'destination'; /** * A copy move's confirmation, written with the bind that routes the scope onto its destination * (#1722). The bind lands only while every entry of the move is still pending, leased to the move * (no sweep has claimed it) and inside its lease; in the same write the destination entries * settle `done` (the route reaches them) and the source entries settle as the `source` field * says, with `sourceMarker` as the marker a later fenced wipe expects. */ export interface ScopeCopyMoveConfirmation { moveId: string; source: 'eligible' | 'retained'; sourceMarker?: { loadStamp: string | null; revision: string | null; }; } /** Directory inventory of one script's copy of a scope (#1722). */ export interface ScopeScriptCopy { tenantId: TenantId; scopeId: ScopeId; scriptRef: string; moveId: string; state: 'pending' | 'eligible' | 'retained' | 'kept' | 'done'; loadStamp: string | null; revision: string | null; role: ScopeCopyRole | null; /** Until when the pending entry belongs to its move (or to the sweep that claimed it). */ leaseUntil: string | null; } /** One staff resolution of a kept copy (#1722), as `recordKeptCopyResolution` logs it. */ export interface KeptCopyResolution { /** `discard` wiped the kept copy; `restore-forward` restored it over the live store, then wiped it; * `release` cleared the marker of a kept copy that is the live store after all. */ action: 'discard' | 'restore-forward' | 'release'; /** The script that held the kept copy. */ script: string; /** The script the scope routes to, which a restore forward replaced; null for a discard. */ liveScript: string | null; /** When the copy was kept, from its marker. */ keptAt: string; /** The kept copy's write revision the operator acted on. */ revisionBefore: string | null; /** The write revision of the store the resolution changed, after it. */ revisionAfter: 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 { /** * The same host, with every admin-log row written through it — by `admin` or by a * host-level write such as `provisionScope` — carrying `onBehalfOf` (#977). `actor` * stays the credential that executed: a service acting for a signed-in person is * recorded as both, not as whichever one the transport happened to hold. The view * is independent, so two concurrent requests attributed to different people never * see each other's person. * * Optional so a host that predates it still satisfies the interface; a transport * that finds it absent writes unattributed rows, which is what every row was before. * * A view of the host knows nothing of any event: an executor handler attributes from the * `admin` it is handed instead (`HostAdmin.attributed`), which keeps its `causedBy`. */ attributed?(onBehalfOf: OnBehalfOf): ScopeHost; /** * The same view, its rows also naming `options.causedBy` as the event they are the effect * of (K-22, #2055). * * @deprecated since #2069: an executor handler attributes from the `admin` it is handed — * `admin.attributed(onBehalfOf)` — which carries its event by construction. This form needs * the event passed again, and a handler that forgets writes `causedBy` NULL with nothing * failing. It stays because it shipped, and because it is how the membership executor still * attributes on a host whose handed admin predates `HostAdmin.attributed`. */ attributed?(onBehalfOf: OnBehalfOf, options?: { causedBy?: string; }): 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; /** * Mint a stub for one scope store (#2207). A door of its own, beside `getScope`: * `ScopeStubOptions` carries no authority, and a store is not a flag on that bag. * * The host checks the (tenant, scope) pair the way `getScope` does, then builds * the object name itself. `binding` is the declaration. The vertical does not * choose the tenant, the scope, or the vertical slug that go into the name. * * `options` carries no authority (#2245). Each invoke is recorded on the * request now running — binding, operation, outcome, duration, never the input — * with nothing passed by the vertical (RFC §6). `onStoreCall`, when passed, is * heard instead. A call made where no request is current is not logged. */ getScopeStore(principal: PrincipalId, tenantId: TenantId, scopeId: ScopeId, binding: string, options?: ScopeStoreStubOptions): 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; /** * Read attachment bytes as a registered module's system principal. Jobs need * the same attachment target gate as a person, without borrowing a person's * identity or sending bytes through invoke. Only open is exposed: a background * extraction job has no reason to mutate attachment metadata or bytes. */ getSystemAttachments(moduleId: ModuleId, 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: ScopeDumpInput): 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. * * `opts.sourceScopeId` (#1869) is a separate hint for the grant re-point ONLY: the scope the * uploaded file says it came from, when the dump's own `scopeId` was set to something else. * The dump's `tenantId`/`scopeId` are left exactly as they are for everything else. * * A host with scope stores then re-erases every tombstoned subject (#2207, * `replayStoreErasures`): the target's marks, and the source's (`sourceScopeId`, else the * dump's `scopeId`) when the source is a scope of this tenant on this host. An unknown * source never refuses the restore; the result says `storeErasureSourceUnknown`. With nothing * re-erased and nothing missing (no store here, or no tombstone that applies) it returns * nothing, as a restore always did. */ restoreScope(actor: PlatformActorId, tenantId: TenantId, scopeId: ScopeId, dump: ScopeDumpInput, opts?: { sourceScopeId?: ScopeId; }): 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. * * **Never on a scope that is not primary** (#2005). A fork, a snapshot or a preview causes * no outbound effects, so its deliveries are journaled terminal with `INERT_SCOPE_REASON` * (counted in `ExecutorDrainReport.inert`) and no handler — executor or connector — runs. */ 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**. * * **It is the one door for that authority** (#1834). A schedule's fire and a job run's * `pass.scope()` both act through it, and so does `getSystemAttachments`. On the hosted adapter it * also refuses (`forbidden`) a module a PITR rewind holds off (#1819), at every call it makes, * not only when it is opened: each call is pinned to the scope instance the door checked, and * a call landing on a restarted one (a rewound scope always is) is checked again first. */ getSystemScope(moduleId: ModuleId, tenantId: TenantId, scopeId: ScopeId): Promise; /** * The CONSUMER half of a cross-vertical edge (#1705): hand this scope a batch another * vertical exported, read by `HostAdmin.readExportedEvents` in the producer's scope. * * Platform-called, never module- or HTTP-reachable. `batch.source` is the producer as the * platform resolved it from its directory. Nothing a vertical sent decides it. * * - **Applied under a compare-and-set** on this scope's watermark for the source. If it is * not `batch.after`, nothing runs and the answer is `stale`. An overlapping pass cannot * move the watermark backwards. * - **Each (event, module) runs in its own transaction with its journal row**, the in-scope * consumer's shape, so a redelivered event finds its row and does not run twice. A * handler that throws is dead-lettered, and the events behind it are still delivered. * - **Withheld events are journaled as dead letters** for each module that imports the type, * with the producer's reason and no payload. What was sent and not delivered is visible * where this scope's operator already looks. * - The watermark moves to `batch.next` LAST, in this scope's own store. A crash before that * redelivers the batch, and the journal absorbs it. * * After the batch, the scope's ordinary dispatch runs in the same tail, so what the * handlers emitted reaches this scope's consumers under the usual cascade cap. Their emits * carry `caused_by` = the producer's event id, which `_substrat_imports` resolves to the * source. */ deliverToPeer(tenantId: TenantId, scopeId: ScopeId, batch: ImportBatch): Promise; /** * Trade a capability's SECRET for what it grants (#1672) — one counted use. * * `act`: a session token to present to `getCapabilityScope`, valid until the earlier of * `CAPABILITY_SESSION_TTL_MS` and the capability's own expiry. The harness sets it as an * HttpOnly cookie, so the secret itself is presented exactly once and never lives in * browser history or a `Referer` (`@substrat-run/vertical-host`'s * `mountCapabilityExchange`). `become`: the principal the holder becomes, for the * harness's identity directory to bind. * * `null` for an unknown, expired, revoked or used-up secret — one answer, so a probe * learns nothing. The use is atomic: a single-use secret exchanged twice at once admits * one. The exchange is on the spine as `capability.exercised`, actor `{ capability }`. * Same fail-closed (tenant, scope) and lifecycle gate as `getScope`. * * `options.mode` names the only kind the caller can handle; a secret of the other kind * answers `null` WITHOUT taking a use — so a claim link pasted into a share-link route is * refused rather than spent. */ exchangeCapability(tenantId: TenantId, scopeId: ScopeId, secret: string, options?: { mode?: 'act' | 'become'; }): Promise; /** * A scope stub whose authority is a CAPABILITY (#1672) — the fifth door, beside the * principal, connection, system and impersonation ones, and a door rather than a flag for * the reason each of those is: what differs is the authority. * * The session is re-resolved on EVERY invoke, inside the operation's transaction: a * revoked or expired capability, or an expired session, is refused `unauthenticated` * before the handler runs, and an operation outside the capability's allowlist is * refused `forbidden`. Neither is a K-35 denial — no key was checked. Inside, the * operation is ordinary: `ctx.check` resolves the capability's grant (its entity subtree, * its keys, its minter's authority now), refusals ARE recorded with actor * `{ capability }`, and so is every event it emits. `ctx.principal` carries the * capability id so the type holds; it is **not a person**. */ getCapabilityScope(sessionToken: string, tenantId: TenantId, scopeId: ScopeId, options?: ScopeStubOptions): Promise; /** * The attachment surface for a CAPABILITY session (#1686) — the mirror of * `getConnectorAttachments`, so a link share of a folder can deliver the files under it, * where `getCapabilityScope` reaches `invoke` only. * * **Reads are the checker's, as the capability.** `list` and `open` check the target's * `readPermission` on the owning entity as `{ capability }` — the same evaluator branch an * invoke meets: the capability's own keys, its entity subtree, and its minter's authority * NOW. So a capability reads no attachment its minter could not read at this moment, and * none outside its subtree. The session is re-resolved on EVERY call, as on the invoke * door: a revoked or expired capability, or an expired session, refuses `unauthenticated` * before any key is checked. An operations-narrowed capability refuses reads by default; * its mint may set `attachments: 'read'` to opt into list, open and search. That field * does not replace the `readPermission` check above. A read never takes a use — a use is * an exchange. * * **Writes are refused.** `upload` and `remove` throw `capabilityAttachmentWriteRefused` * whatever keys the capability carries, recorded in the denial log against * `{ capability }`; no byte reaches the blob store. * * OPTIONAL, like `checkFreshness`: a host built before it, or a third-party adapter, * satisfies this interface without it, and a caller feature-detects it rather than * assuming it (`@substrat-run/vertical-host`'s `linkShareAttachments` refuses the request * when it is absent, never falls back to the signed-in principal). Same fail-closed * (tenant, scope) and lifecycle gate as `getCapabilityScope`; throws when no blob store is * provisioned for the scope's vertical, exactly like `attachments`. */ getCapabilityAttachments?(sessionToken: string, tenantId: TenantId, scopeId: ScopeId): Promise; /** * A scope stub whose authority is ANOTHER VERTICAL of the same tenant (#1706) — the sixth door, * beside the principal, connection, system, impersonation and capability ones, and a door * rather than a flag for the reason each of those is: what differs is the authority. * * **Who is calling is the platform's word, not the caller's.** `caller` is supplied by the hop * that identified the calling deployment — the router on the hosted path, the local broker on * the pure host — which has already checked that the caller is a live, primary instance of the * SAME tenant and resolved this scope as the target. The door's own guarantees are the * scope's, checked here and again inside the scope on every invoke: * * - K-3's fail-closed (tenant, scope) pair and the lifecycle gate, as on every door; * - **admission** (`admitPeer`), in the scope's serialized task on EVERY invoke: the caller is a * declared peer (`peers` in a registered manifest), its kill switch is on, and the operation * is on its allowlist. Each refusal is `forbidden` and none is a K-35 denial (no key checked); * - then an ordinary operation: `ctx.check` resolves the `vertical:` grants the manifest * declared (seated at provisioning), refusals ARE recorded with actor `{ vertical, scope }`, * and so is every event it emits. * * There is no principal anywhere in it: the caller's signed-in user, if its request had one, * does not cross. `ctx.principal` carries a value so the type holds; it is **not a person**, and * the verbs that need one — minting a capability — refuse it. */ getVerticalScope(caller: VerticalCaller, tenantId: TenantId, scopeId: ScopeId, options?: ScopeStubOptions): Promise; /** * Does peer `vertical` hold each of `permissions` at this scope's node RIGHT NOW (#1706) — the * checker's own answer (`covers`), from the scope's own tuples. A peer switched off holds * nothing (its grants are tombstoned), a key its manifest entry never declared is not held, and * an undeclared peer holds nothing at all. What a platform verb asks before it hands a peer * something the peer's own door would have checked — #1705's gate on an exported event. */ peerCovers(tenantId: TenantId, scopeId: ScopeId, vertical: string, permissions: readonly PermissionKey[]): Promise; /** * May `principal` confer `roleKey` at this scope's node (#1931)? `ctx.canAssign`'s bound for a * caller the HOST names — what a harness route asks before it grants a role, or takes one * back, outside any operation. The platform's invite routes are that harness: they resolve who * is inviting and at which role, and there is no operation context to ask from. * * The same answer `ctx.canAssign` gives that principal, by construction rather than by a * second copy: the tenant's projected role, then the checker's own `covers` at the scope node. * So it is narrowing-aware (an entity-narrowed grant does not satisfy it), membership expands, * and an expired grant confers nothing. Throws `not_found` when `roleKey` names no role in * this tenant, as `ctx.canAssign` does. * * It is a READ and records nothing — neither a denial nor an admin-log row. The caller that * refuses on it is the one that knows what was refused. */ canAssign(tenantId: TenantId, scopeId: ScopeId, principal: PrincipalId, roleKey: string): Promise; /** * Check the caller's assignment bound and grant `assignee` the scope role in one scope * task. A refused bound writes nothing and returns its missing permissions. The role * definition and permission tuples used by the check are the same ones used by * `canAssign`; an unknown role throws `not_found`. */ assignScopeRoleBounded(tenantId: TenantId, scopeId: ScopeId, caller: PrincipalId, assignee: PrincipalId, roleKey: string): Promise; /** * The scope's role roster (#1150): every live scope-level role assignment, one row per * (principal, role) — or one principal's, with `principal`. One scope, addressed by the caller * after its own (tenant, scope) check — K-3 is asserted here too — so it is a read of this * scope, never a walk across scopes. */ listScopeRoleHolders(tenantId: TenantId, scopeId: ScopeId, principal?: PrincipalId): Promise; /** * Move `principal` from scope role `from` to `to` in ONE scope task (#1150): the caller's * bound is asked over both roles (taking `from` away is bounded like granting it, §5.1 * consequence 1), then `from` is tombstoned and `to` granted together. Nothing is written * on a refusal, so nobody is left holding both roles or neither. Throws `not_found` for a * `to` this tenant does not define, and `conflict` when `principal` does not hold `from`. */ changeScopeRoleBounded(tenantId: TenantId, scopeId: ScopeId, caller: PrincipalId, principal: PrincipalId, from: string, to: string): Promise; /** * Take every scope role `principal` holds, in ONE scope task (#1150), bounded by the * caller's authority over each (§5.1 consequence 1: you cannot strip what you could not * have granted). A role the tenant no longer defines confers nothing and is taken without a * bound. A refusal writes nothing; `revoked` names what was taken. */ revokeScopeRolesBounded(tenantId: TenantId, scopeId: ScopeId, caller: PrincipalId, principal: PrincipalId): Promise<{ coverage: Coverage; revoked: string[]; }>; /** * 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[]; /** * What the modules registered on this host import from other verticals (#1705): one row per * (source vertical, event type), from their `consumes: [{ from }]`. Code-time bookkeeping, like * `registeredSchedules`. The cross-vertical phase reads it to call NO scope on a host that * imports nothing. Optional, and feature-detected: a host without it is read as importing * nothing. */ registeredImports?(): { from: string; type: string; schemaVersion: number; }[]; /** * Whether scopes bound to a vertical are served by that vertical's OWN deployment rather than * by this host (#1705 PR 3): true on the shared control plane. Such a host's own verbs cannot * see those scopes, so a cross-vertical read with no reach of its own must say it cannot answer * rather than report "no edges". Optional: absent reads as false. */ servesScopesElsewhere?(): boolean; /** * 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. * * `options.leaseMs` (#2034) is how long one pass holds its run without reaching a * step boundary, which renews it — default `JOB_LEASE_MS`. Past it, the run is due * again and the next drive takes it over, counting the silent pass as a failed * attempt. A job whose pass may go longer than that between two steps says so here. * * 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, options?: { leaseMs?: number; }): 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. * * **Overlapping calls are safe** (#2034): each run is CLAIMED before its pass, with a * lease the pass renews at every step boundary, so two calls on one scope never run * one run's handler together. A run whose pass died is taken over once its lease * expires. The full argument 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. * * A row whose identity does not decode is returned as an `UndecodablePlatformRequest` (#1637), * never thrown for the queue; the drain runs no handler on one. */ 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. Returns an * `UndecodablePlatformRequest` for a row whose identity does not decode (#1637), as the * pending read does. */ 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; /** * #2102: an outcome event written into the scope with the settle, in its transaction, * and only when this settle moves the row out of `pending` (`settlePlatformRequestIn`). */ event?: PlatformOutcomeEvent | 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. * * A subscription may be narrowed `within` one entity (#1853), which only removes frames. * Two built roots replace the per-row check with one about the ROOT, and their subscribers * are sent bare `LiveNudge` frames that name no entity: `checkedWithin`, whose principal must * pass a stated key on the root at the handshake and again on every pass that has something * to send (#938), and `vouchedWithin`, which the vertical asserts once and nothing re-checks * (#1853). Which to use is on `checkedWithin`. * * **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; /** * Narrow the feed to one entity and what hangs beneath it (#1853). * * A frame is then delivered only if its entity IS this one or reaches it upward * through live declared `parent` edges (what `ctx.link` / `ctx.relink` write) — the * walk `ctx.check` makes, rooted here instead of at a grant. * * - **An `EntityRef` narrows and nothing else.** The principal's own per-frame * `liveTargets` check still runs; `within` is ANDed with it, so it can only take * frames away. A screen watching one conversation passes it to stop hearing the * rest of the desk. * - **A `vouchedWithin(…)` value replaces the principal's check.** For a subscriber * confined by something other than a grant — a widget visitor holding a session * token the vertical has just redeemed. The vertical asserts access to the root, * the same trust it already extends in naming `principal`, and the scope's walk is * the whole filter. Such a subscriber receives `LiveNudge` frames only, which name * no event type and no entity. * - **A `checkedWithin(…)` value replaces the per-row check with one on the root** * (#938). The principal must pass the stated key on the root, at the handshake (a * `403` otherwise) and again on every pass that has a row beneath it to announce, * so a grant withdrawn, or a root moved out from under it, closes the socket * instead of nudging it. `LiveNudge` frames only, as for a vouched root. * * **Freshness (Codex #2077 r5).** A decision is reused within a pass only while the * scope has had no write and nothing it holds has reached its `expires_at` since the * decision. It is never reused while the scope reads its permissions from the directory. * Both are checked against the clock immediately before each send. A change that lands * while a check is still being evaluated can let that one frame through. Frames name * nothing the subscriber could not read when checked, and the next frame, pass and poll * see the change: a push is a hint at most one evaluation stale. */ within?: EntityRef | VouchedWithin | CheckedWithin; /** * When the credential that proved `principal` stops being valid (ISO 8601), so the * socket never outlives it (#938). Pass the session's or the bearer's expiry * (`AuthSubject.expiresAt`). A handshake at or past it is refused, and the scope closes * the socket (`1008`) on its first pass at or past it, before sending anything on that * pass. Absent: the socket lives until either end closes it, as before. */ expiresAt?: string; }): Promise; } /** The brand only `vouchedWithin` can apply — a literal cannot type-check as one. */ declare const vouchedBrand: unique symbol; /** * A `within` root the VERTICAL vouches the subscriber may watch, in place of the * principal's own grants (#1853). Built only by `vouchedWithin`. */ export interface VouchedWithin { readonly [vouchedBrand]: true; readonly entity: EntityRef; /** Why the vertical vouches — what it checked. Required, and kept on the subscription. */ readonly because: string; } /** * Vouch that the subscriber may watch `entity` and everything beneath it, though the * principal it subscribes as holds no read on it. * * The one way to reach the replacing mode, so every use is greppable and none is an * accident: an object literal is refused by the type checker, and anything not built * here is refused by the host at run time (`isVouchedWithin`). Call it only after the * vertical itself has proven access — for ticket0's widget, by redeeming the session * token for the session it names. * * What it costs the subscriber is detail: a vouched feed carries `LiveNudge` frames, so * the scope never tells it which entity changed or how. Root it at an entity whose * subtree holds only what the subscriber may see — the walk is the whole filter. */ export declare function vouchedWithin(entity: EntityRef, opts: { because: string; }): VouchedWithin; /** Was this value built by `vouchedWithin`? A host asks before it drops the principal's check. */ export declare function isVouchedWithin(value: unknown): value is VouchedWithin; /** The brand only `checkedWithin` can apply. */ declare const checkedBrand: unique symbol; /** * A `within` root the PRINCIPAL must be allowed `permission` on, re-checked for as long * as the socket is open (#938). Built only by `checkedWithin`. */ export interface CheckedWithin { readonly [checkedBrand]: true; readonly entity: EntityRef; readonly permission: PermissionKey; } /** * Watch `entity` and everything beneath it, for as long as the subscriber may * `permission` on `entity` itself. * * For a subscriber whose grant reaches the ROOT but not each row under it the way a * `liveTargets` key would: ticket0's portal customer holds `conversation:read-own` on * their contact, which reaches the conversation's public thread, while the per-row key * on a message is the staff read. Rooted at an entity whose subtree is exactly what that * key's read returns, the walk is the row filter and the key is the gate. * * The gate is asked at the handshake (`403` if it refuses) and again, once per socket, * on every post-commit pass that has a row beneath the root to announce. A refusal or a * check that throws closes the socket (`1008`) and sends nothing on that pass, so a * revoked grant, a principal who is gone, or a root relinked out of the grant's reach * ends the subscription instead of nudging it, and the client's reconnect meets the * handshake's `403`. A pass with nothing beneath the root asks nothing, so an idle * subscription costs no checks. * * **`checkedWithin` or `vouchedWithin`?** Use `checkedWithin` whenever the subscriber is * a principal with a grant that reaches the root: authority leaves with the grant. Use * `vouchedWithin` only when there is no such grant to ask, as for ticket0's widget * visitor, who holds a session token rather than a principal of their own. A vouched * socket is asserted once, so a token revoked mid-socket keeps receiving content-free * nudges until the socket closes. That is known and accepted for the widget: the nudge * names nothing, and every re-read it causes is checked again. * * Both send `LiveNudge` frames only: the subscriber may not read each row, so it is * never told which one changed. Only rows of a type some module declared in * `liveTargets` are announced, as for every other subscriber. */ export declare function checkedWithin(entity: EntityRef, permission: string): CheckedWithin; /** Was this value built by `checkedWithin`? */ export declare function isCheckedWithin(value: unknown): value is CheckedWithin; /** * Is this request asking to be upgraded to a WebSocket? * * Paired with `LIVE_MODE_HEADER` (`@substrat-run/contracts`) because a vertical's live * route asks it first, before anything else it decides: only a WebSocket handshake is a * live read, and a browser always sends `Origin` on one, so everything past this check * can trust that a missing `Origin` did not come from a browser page. A plain GET with a * cookie on it cannot. */ export declare function isUpgradeRequest(request: LiveUpgradeRequest): boolean; /** * 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; } /** * What a vouched or checked subscriber is told (#1853, #938): something beneath its root * changed, and nothing else. * * No event type and no entity, deliberately. Such a subscriber holds no read on the * entities its frames are about — the vertical vouched for the ROOT, or the principal was * checked on the root alone, and the scope cannot know which rows under it the vertical's * own read would show. Naming the type * or the id would tell it what it never asked to read. The client re-reads, as it does * on a `LiveChange`. */ export interface LiveNudge { readonly kind: 'nudge'; /** The event id, as on `LiveChange` — for ordering and de-duplication only. */ readonly id: string; readonly at: string; } /** Any frame a live read sends. */ export type LiveFrame = LiveChange | LiveNudge; /** * 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