import { type ConnectorHandler, type OpsFailureInput, type ScopeHost } from '@substrat-run/kernel'; import { type PlatformActorId, type PlatformRequest, type PlatformRequestFailure, type ScopeId, type TenantId } from '@substrat-run/contracts'; import { type VerticalClient } from './vertical-client.js'; import type { PatchScriptBindingsFn } from './wfp.js'; /** * The platform-intent drain engine (Phase B2 of docs/architecture/platform-intents.md). A vertical * enqueues typed intents in its own scope DO (`ctx.requestPlatform`, Phase A); because those rows * live in the vertical's deployment (K-31), the platform PULLS them over the vertical's `/internal` * surface (`VerticalClient`, Phase B1's read/settle exposed there) and executes each with its own * `HostAdmin` authority. Identity is inherent: the caller already holds `(tenant, scope, vertical)` * for the scope being drained — nothing is asserted by the vertical. */ /** The scope an intent was drained from — the platform's authoritative context, from its directory. */ export interface PlatformRequestContext { tenantId: TenantId; scopeId: ScopeId; vertical: string; /** * The version-registry id bound to the scope at drain time (the signals `version` * stamp, #1231) — an approximation of the version that enqueued the intent, which * is why it is optional rather than required: a caller that cannot say should say * nothing, not guess. */ versionId?: string | null; } /** What a handler reports for one intent; `result` is persisted (COALESCE'd) for two-phase idempotency. */ export interface PlatformRequestOutcome { status: 'done' | 'failed' | 'pending'; result?: unknown; error?: string; /** * WHO refused (#841) — journaled beside `error` so a reader never has to infer it from the * message. A handler that does not attribute leaves this undefined and the column stays * NULL, which reads as "nobody classified this" rather than a guess nobody made. */ failure?: PlatformRequestFailure; } /** Executes one intent kind with platform authority. Registered per `kind` (mirrors connector sweepers). */ export type PlatformRequestHandler = (ctx: PlatformRequestContext, request: PlatformRequest) => Promise; export interface PlatformDrainReport { drained: number; done: number; failed: number; pending: number; } /** * How many drain passes a pending intent may burn before the platform stops retrying and * settles it `failed` (#570). Every pass through the ceiling means a HANDLER judged the * failure transient — but an intent that has been "transient" this many times is * structurally stuck (a wrong dispatch target, a refusal upstream of the platform), and * before this ceiling its only trace was an `attempts` column nobody reads. At the * ~15-min sweep cadence 100 attempts ≈ a day — generous for any genuinely transient * fault, short enough that the proposer learns the truth while it still matters. */ export declare const MAX_PLATFORM_REQUEST_ATTEMPTS = 100; export interface PlatformDrainOptions { /** * Land a durable ops-failure row (#559) when the drain gives up on an intent at the * attempt ceiling. Fire-and-forget at the call site: a recorder that throws must never * mask the settle it is recording. The drain adds the `actor` upstream. */ recordFailure?: (entry: Omit) => void; /** Test override of {@link MAX_PLATFORM_REQUEST_ATTEMPTS}. */ maxAttempts?: number; } /** * Drain one scope's pending platform intents: list them from the vertical, dispatch each to the * handler for its `kind`, and settle the outcome back in the vertical. An unknown kind settles * `failed` (never silently dropped); a thrown handler settles `pending` (retried on the next drain). * The `VerticalClient` transport is narrowed so tests can pass a fake. */ export declare function drainScopePlatformRequests(client: Pick, ctx: PlatformRequestContext, handlers: Record, opts?: PlatformDrainOptions): Promise; export interface ProvisionSiblingDeps { host: ScopeHost; actor: PlatformActorId; /** Resolve the `VerticalClient` that serves a scope (its serving script / bound version). */ resolveVerticalForScope: (scope: { vertical: string | null; verticalVersionId: string | null; servingRef?: string | null; }) => Promise; /** * Attach per-tenant store D1 bindings on the dispatch script (#301) — threaded through * to `collectTenantStoreHandles` so a sibling provision heals a missing attach exactly * as a first install would. A sibling shares its tenant's stores (they are per TENANT, * not per scope), so this usually resolves existing handles and no-ops the patch. */ patchScriptBindings?: PatchScriptBindingsFn; } export interface ProvisionSiblingInput { tenantId: TenantId; parentScopeId: ScopeId; scopeId: ScopeId; slug: string; name: string; owner: string; } export type ProvisionSiblingResult = { ok: true; scopeId: ScopeId; } | { ok: false; status: number; error: string; }; /** * Provision a SIBLING scope of `parentScopeId` — the single home for the sequence M1's * `POST /tenants/:tenantId/scopes` route runs and the drain's `provision-sibling` handler runs. * Inherit the parent's vertical + jurisdiction (directory row FIRST, K-31 two-phase), materialize * the instance in the vertical's deployment, activate. `provisionInstance` throws * `ControlPlaneError` on a vertical-side refusal (the caller decides retry vs surface); a * missing parent or unbound deployment returns `{ ok: false }`. */ export declare function provisionSiblingScope(deps: ProvisionSiblingDeps, input: ProvisionSiblingInput): Promise; /** * The `provision-sibling` platform-request handler — reuses `provisionSiblingScope` with the drained * scope as the parent. Two-phase idempotency: a scope id minted on an earlier pass (recorded in the * intent's `result`) is reused, so a retry targets the same sibling and provisioning is a no-op * (K-31). A `ControlPlaneError` from the vertical is transient (`pending`, retried); a structural * failure (missing parent, unbound deployment) is terminal (`failed`). */ export declare function provisionSiblingHandler(deps: ProvisionSiblingDeps): PlatformRequestHandler; export interface ArchiveScopeDeps { host: ScopeHost; actor: PlatformActorId; } /** * The `archive-scope` platform-request handler — archives a sibling scope named in the intent. * The scope being drained proves the tenant; the target must be under that same tenant and run the * same vertical (checked against the directory), so a vertical can only ever archive its own * tenant's scopes. Idempotent: an already-archived/reaped/absent target is a no-op success, so a * retry never wedges. */ export declare function archiveScopeHandler(deps: ArchiveScopeDeps): PlatformRequestHandler; /** * Deps for the MANAGED-TENANT handlers (#412) — `provision-tenant` / `set-entitlements`, * the capability that makes a vertical a manager (a console whose job is to add tenants). * Unlike `provision-sibling`, the target tenant is NOT proven by the drained scope (it may * not exist yet), so admissibility is bounded on the manager instead: * * 1. the `tenantProvisioner` registry flag — the staff-granted provisioner capability * (#444), flipped by `setVerticalTenantProvisioner` and read here at drain time. * An ungranted vertical settles `failed` — the refusal distinguishes *undeclared* * (the manifest never asked, #455) from *declared-but-ungranted* (awaiting the staff * grant), so a vertical author gets a self-serve diagnosis instead of a dead end. * 2. SKU bound — every entitlement key a payload names must be in the manager's * registry-declared `entitlements` (its manifest's, carried on push), read at drain time. * 3. target bound (#412 invariant 4) — when the manager DECLARES `provisions`, a * `provision-tenant` payload's vertical must be among the declared targets. Phased: * a granted manager with no declaration keeps its unbounded #444 behavior until its * next push declares (then the bound engages, reviewably). * * Still phased (#412 invariant 2, before any third-party manager): `tenants.provisionedBy` * ownership — until it exists, a listed manager can `set-entitlements` on any tenant. */ export interface ManagedTenantDeps { host: ScopeHost; actor: PlatformActorId; /** Resolve the `VerticalClient` serving a scope — same ladder as `ProvisionSiblingDeps`. */ resolveVerticalForScope: (scope: { vertical: string | null; verticalVersionId: string | null; servingRef?: string | null; }) => Promise; /** Attach per-tenant store D1 bindings (#301); a NEW tenant's first install mints here. */ patchScriptBindings?: PatchScriptBindingsFn; } /** * The `provision-tenant` platform-request handler (#412): create a NEW customer tenant, its * first scope running the PAYLOAD's vertical (never inherited — the manager and the managed * product are different verticals), grant its entitlements, then the same materialization a * first install runs: resolve the serving deployment, mint/collect the tenant's stores * (#301), `provisionInstance` (which projects the entitlements, #310), deliver `config`, * activate. Every id is proposed in the payload (idempotent join keys), so an at-least-once * drain converges without minting anything on retry: `createTenant`/`provisionScope`/ * `provisionInstance` are all idempotent no-ops the second time. A `ControlPlaneError` from * the vertical is transient (`pending`); a structural refusal (capability, SKU bound, slug * taken by a different tenant, unbound deployment) is terminal (`failed`). */ export declare function provisionTenantHandler(deps: ManagedTenantDeps): PlatformRequestHandler; /** * The `set-entitlements` platform-request handler (#412): reconcile a managed tenant's * grants to the payload's TARGET set, bounded both ways by the manager's declared SKU * universe — grant what the target names, revoke any declared key absent from it — then * re-project into the tenant's auth scope via the vertical's idempotent reconcile * (entitlements re-gathered authoritative + identity links, exactly as the repair route, * #310/#406; the vertical-side projection is a full replace, so a downgrade revokes * cleanly). Keys OUTSIDE the declared universe are never touched, so a platform-granted * entitlement (e.g. the tenant's own product SKU) survives any manager reconcile. */ export interface ConnectorDispatchDeps { host: ScopeHost; /** * The provider's handler — the SAME closure a self-host registers in-process * (e.g. `scriveConnector({...})`). The connector does not fork for hosting; only * the host running it changes. */ connector: ConnectorHandler; /** Per-request egress timeout for this provider. Default 30s, the registration default. */ timeoutMs?: number; } /** * The `connector:` platform-request handler (#574 phase 3) — the outbound half * of the platform-run connector pass. A CP-less vertical's host routed one connector * delivery here as an intent; this executes it with platform authority: open the tenant's * connection from THIS directory, egress to the provider, mint the webhook token, write * the dispatch ledger — all inside the connector's own closure via `host.dispatchConnector` * — and the drain settles the intent from the outcome. Registered once per provider the * platform operates, exactly as its sweepers are. * * A thrown handler (provider unreachable, no live connection yet) settles `pending` and * retries on later drains up to the attempt ceiling; the connector's dispatch ledger makes * those retries idempotent. An event whose kernel-stamped tenant/scope disagree with the * drained scope's is terminal: intents ride the scope's own spine table, so the drained * scope is the proven origin and a mismatched payload can only be a forgery or a bug. */ export declare function connectorDispatchHandler(deps: ConnectorDispatchDeps): PlatformRequestHandler; export declare function setEntitlementsHandler(deps: ManagedTenantDeps): PlatformRequestHandler; /** * `model-usage` (#1054): a vertical's model host produced one `ModelUsageLine` and raised * it as an intent; this records it in the platform's ledger (meter 3). Two refusals are * terminal rather than retried: a payload that is not a line, and a line whose * attribution names a tenant or scope other than the one being drained — the platform * knows (tenant, scope, vertical) inherently from the DO it read, and a vertical does not * get to bill another. Idempotent on the intent id, so a replay settles `done` with * `recorded: false` instead of writing twice. */ /** * #1232: land a CP-less pass's batched schedule outcomes in `_substrat_sweep_runs`. * The payload carries NO tenant/scope/vertical — identity is proven by the scope the * intent physically lives in (the model-usage rule), so nothing in it can mislabel * its origin. `kind`/`unit` are derived here, never trusted. The write is idempotent * on (request.id, unit), so a replayed drain — a settle lost in transport, a partial * batch re-run — writes nothing twice. */ export declare function sweepRunsHandler(deps: { host: ScopeHost; }): PlatformRequestHandler; export declare function modelUsageHandler(deps: { host: ScopeHost; }): PlatformRequestHandler; //# sourceMappingURL=platform-drain.d.ts.map