import type { AccessLogEntry, ConnectionId, MigrationProgress, MigrationStraggler, PlatformActorId, ScopeId, TenantId, DrainedEvent } from '@substrat-run/contracts'; import type { ExecutorDrainReport, FetchLike, ScopeHost, SweepRunInput } from './scope-host.js'; /** * A connector's reconcile sweep — the unit `runPlatformSweep` calls per live * connection of a given provider (`sweepScriveReconciliations` is one). * * INJECTED, never imported: the driver — and the deployment that runs it — * depends on no specific connector. A connector contributes `{ [provider]: its * sweeper }` to the registry, and a provider with no entry is simply skipped. */ export interface ConnectorSweeper { (host: ScopeHost, connectionId: ConnectionId, opts: { fetch: FetchLike; }): Promise; } /** * Where drained access-log rows go — Tier 2 (K-24), the durable place a row lives * once it has left the directory. * * INJECTED for the same reason `ConnectorSweeper` is: the kernel names the seam and * knows nothing about the target. The control plane binds an R2 implementation * (`createR2AccessLogSink`); a self-host may bind a file, an object store, or nothing * at all — and nothing at all is a supported answer, it just means the log is never * pruned. * * `ship` MUST be durable before it resolves. Everything downstream — the `drainedAt` * stamp, and the prune the stamp licenses — treats a resolved `ship` as proof the * evidence survives outside the directory. A sink that buffers and returns early turns * a retention policy back into data loss. */ export interface AccessLogSink { /** * Ship one batch and return an opaque reference to where it landed (an object key, * a URL — the sweep only records it). The reference is what makes the admin-log row * actionable: "these rows left, and here is where they are". */ ship(entries: AccessLogEntry[]): Promise<{ ref: string; }>; } /** * Where a scope's drained domain events go — Tier 2 proper (#1334, master-plan * §5.3: "domain events → Pipelines → Iceberg on R2, queried via R2 SQL"). * * The `AccessLogSink` twin, and injected for the same reason: the kernel names the * seam and knows nothing about the target. The control plane binds an R2 * implementation; a self-host may bind a file, an object store, or nothing at all * — and nothing at all is supported, it just means the outbox is never drained. * * `ship` MUST be durable before it resolves, and the stakes are higher here than * for the access log: a resolved `ship` is what licenses stamping `drainedAt`, and * the whole promise of Tier 2 is EXACT history. A sink that buffers and returns * early turns "the lake has everything" into a claim nobody can check. * * Unlike the access log, a drained event is NOT pruned from the scope afterwards. * Pruning the outbox is its own decision — consumers, replay and `readHistory` all * read it — so this seam only ever ships and stamps. What the stamp buys today is * knowing what has left; what it licenses later is a retention policy that does * not exist yet. */ export interface EventSink { /** * Ship one scope's batch and return an opaque reference to where it landed. The * scope is passed alongside because a lake partitions by it — the sink decides * how, the kernel only reports the reference back. */ ship(scope: { tenantId: TenantId; scopeId: ScopeId; }, events: DrainedEvent[]): Promise<{ ref: string; }>; } export interface PlatformSweepOptions { /** * The durable sweep record (#1232): called once per unit outcome — each * connection swept/skipped/failed, each schedule run — with the signals stamp * the frame can honestly carry. UNSET ⇒ the pass records nothing, exactly as * before the seam existed. Sync fire-and-forget: a recorder that throws must * never sink the pass, so callers hand in a closure that swallows its own * errors (the ops-failure recorder's shape, worker.ts). */ recordSweepRun?: (entry: SweepRunInput) => void; /** The platform actor the enumeration reads run as (`listScopes`/`listConnections`). */ actor: PlatformActorId; /** Sanctioned egress handed to each connector sweeper. */ fetch: FetchLike; /** provider slug → its reconcile sweeper. A connection whose provider is absent is skipped. */ sweepers: Record; /** Max scope drains / connection sweeps in flight at once. Default 8. */ concurrency?: number; /** * Also drain each active scope's due executor deliveries — the retry driver * (connections.md §2.1), which has been landed and equally lacks a caller. * Default `true`; set `false` to sweep only connectors. */ drainRetries?: boolean; /** * Also drain each active scope's pending PLATFORM INTENTS (platform-intents.md) — the requests a * vertical enqueued via `ctx.requestPlatform`. Injected like `reapScopeFn`: the kernel cannot * reach a vertical's scope DO (it lives in the vertical's own deployment), so the control plane * supplies a fn that pulls + executes each intent over the vertical's `/internal` surface. UNSET * (the default) skips the phase; returns per-scope counts, summed into `platformRequestTotals`. */ drainPlatformRequestsFn?: (tenantId: TenantId, scopeId: ScopeId) => Promise<{ drained: number; done: number; failed: number; pending: number; }>; /** * Re-run one scope's provision in the vertical's own deployment (#1172). * * Injected like `drainPlatformRequestsFn` and for the same reason: the kernel cannot * reach a vertical (its scope DO lives in the vertical's deployment), so the control * plane supplies the fn that goes over `/internal/reconcile`. UNSET skips the phase. * * What makes the phase necessary: a vertical's `onProvision` runs ONCE per scope, at * install. Anything the vertical mints for itself there — a service principal, a site * registration — therefore never reaches an install that predates it. The new code * deploys, and the thing it depends on was never created. Comparing the scope's bound * version against the one its provision last ran against is how the platform sees that, * and this fn is how it fixes it. */ reconcileScopeFn?: (tenantId: TenantId, scopeId: ScopeId) => Promise; /** * Also reap expired snapshots (preview-and-snapshots.md §3/§9): any FORK * (`forkedFrom` set) whose `expiresAt` has passed is hard-deleted via * `deleteSnapshot`. Default `true` — an expiry is only ever present because the * snapshot's creator asked for one, so sweeping it is honoring that request, and * `deleteSnapshot` refuses non-forks regardless. Set `false` to skip the phase. */ gcSnapshots?: boolean; /** * How the GC phase deletes one expired fork. Defaults to `host.deleteSnapshot` — * right whenever the host and the scope's storage share a deployment (self-host, * a vertical's own sweep). The CONTROL-PLANE cron overrides it with the * orchestrated delete (§9): wipe the fork's storage in the vertical deployment * that actually holds it, then the in-process delete for directory row + audit. */ deleteSnapshotFn?: (tenantId: TenantId, scopeId: ScopeId) => Promise; /** * Also reap long-archived scopes (control-plane.md §4.4): any scope in `archived` * whose `archivedAt` is older than this many days has its DO storage wiped and moves * to `reaped` (Cloudflare never GCs a Durable Object, so nothing else frees it). The * directory row survives as a tombstone. UNSET (the default) skips the phase entirely * — auto-reap is opt-in, and irreversible, so a deployment must name a retention * window before the sweep will ever delete an app's data. A scope with a null * `archivedAt` (archived before this column shipped) is never auto-reaped — it has no * knowable age — and must be reaped by hand. */ reapArchivedAfterDays?: number; /** * How the reap phase reaps one archived scope. Defaults to `host.admin.reapScope` — * right when host and storage share a deployment (self-host, a vertical's own sweep). * The CONTROL-PLANE cron overrides it with the orchestrated reap (§4.4): wipe the * scope's storage in the vertical deployment that actually holds it (its DO is * CP-less), then the in-process reap for the directory transition + audit. */ reapScopeFn?: (tenantId: TenantId, scopeId: ScopeId) => Promise; /** * Also drain the staff access log to Tier 2 and prune what it drained (K-24, * control-plane.md §4.4). UNSET (the default) skips the phase entirely, exactly like * `reapArchivedAfterDays`: a deployment that has named no durable target must not have * its evidence deleted on a schedule, and "no sink configured" is a supported posture * (the log then grows unbounded — stated, not silent). * * Bounded per pass by `accessLogBatch`, not run to exhaustion: a sweep tick has a * budget, and an unbounded first pass over a year of rows is how a cron becomes an * incident. The window closes over several ticks instead of one. */ accessLogSink?: AccessLogSink; /** * Where each scope's domain events are shipped (#1334). UNSET ⇒ no scope is * drained, exactly as before the seam existed — the same "absent is a supported * answer" shape `accessLogSink` and `recordSweepRun` already have. */ eventSink?: EventSink; /** Events drained per scope per pass. Default 200. */ eventDrainBatch?: number; /** Rows shipped (and pruned) per pass. Default 500. */ accessLogBatch?: number; /** * Also reap tenants past their grace window (control-plane.md §4.8): any tenant in * `deleting` whose `deletingAt` is older than this many days has every scope reaped * and its PII/config directory rows cleared, moving it to `reaped` (a tombstone). * UNSET (the default) skips the phase entirely — like `reapArchivedAfterDays`, the * reap is irreversible, so a deployment must name a retention window before the sweep * will ever destroy a tenant's data. A tenant with a null `deletingAt` (flipped before * the column shipped) is never auto-reaped and must be reaped by hand. */ reapDeletingAfterDays?: number; /** * How the reap phase reaps one due tenant. The default composes the existing * `reapScopeFn` seam: for each of the tenant's non-reaped scopes it archives (if * needed) then reaps via `reapScopeFn`, and finally clears the directory via * `host.admin.reapTenant`. Because it rides `reapScopeFn`, the CONTROL-PLANE cron * gets the orchestrated per-scope reap (wipe each DO in its vertical deployment) for * free just by setting `reapScopeFn` — it need not override this. Supplied only to * fully replace the tenant-reap behavior. */ reapTenantFn?: (tenantId: TenantId) => Promise; /** * Also reconcile migrations (kernel-design §5.3, #49): walk the directory for * live scopes behind this host's frontier or failed, wake each with * `migrateScope`, back off between retries of a failing scope, and report * "release N: X/Y migrated, P pending, F failed". Default `true`; the phase * also quietly skips itself on a host that predates `migrateScope`. */ reconcileMigrations?: boolean; /** * Also run each vertical's DUE recurring schedules (#383): for every module that * declares `schedules`, enumerate its live scopes and invoke each due operation * under a system actor via `runDueSchedules`. Default `true`; the phase * feature-detects `host.registeredSchedules` and quietly yields `schedules: null` * on a fake or pre-#383 host, and does nothing when no module declares any. */ runSchedules?: boolean; /** * Backoff between retries of a FAILED scope, keyed off the directory's * consecutive-attempt count: `baseDelayMs * 2^(attempts-1)`, capped at * `maxDelayMs`, jittered ±20% (the same curve executor retries use). There is * no max-attempts: a broken migration heals only by a patched forward release, * so the sweep retries at the capped cadence until one arrives — flagging past * `migrationFlagThreshold` is the human signal, not a stop. Defaults: 60s * base, 1h cap. Never-attempted stragglers are always due — waking them IS the * sweep's job. */ migrationBackoff?: { baseDelayMs?: number; maxDelayMs?: number; }; /** Consecutive failures before a scope is flagged/paged. Default 3. */ migrationFlagThreshold?: number; /** * The paging seam (§5.3 "pages past a threshold"): called once per pass with * the failed scopes at/over the threshold, only when there are any. The * deployment wires it to whatever alerting it has; the same list always rides * `report.migrations.stragglers` (`flagged: true`), so ignoring the callback * loses nothing but immediacy. A throwing pager is caught — it must never * sink the pass it is reporting on. */ onMigrationsFlagged?: (flagged: MigrationStraggler[]) => void; } /** * What the migration-reconciliation phase did in one pass: the fleet progress * AFTER the pass (the §5.3 "release N: X/Y migrated…" numbers, shared shape * with the ops-console view), plus the pass's own work. */ export interface MigrationSweepReport extends MigrationProgress { /** `migrateScope` calls made this pass (stragglers that were due). */ attempted: number; /** Stragglers this pass brought to the frontier. */ repaired: number; /** Failed scopes skipped this pass — their backoff window has not elapsed. */ deferred: number; /** Attempts where this host had nothing pending — the scope's modules run in another deployment. */ noops: number; } /** * Which family a `_substrat_schedule_state` row belongs to (#1288) — the two of * `sweepRunKind`'s three names that a SCOPE can hold gating state for. (`connector` * is the third and is deliberately absent: a connection is swept host-wide and its * state lives nowhere in a scope.) */ export type ScheduleStateKind = 'schedule' | 'freshness'; /** * The platform sweep's per-scope gating state (#383), as both adapters build it. * * Shared rather than spelled twice because `lint:spine-ddl` can compare the copies * only where they are DDL a `KERNEL_DDL` executes — and this table is also rebuilt * by `SCHEDULE_STATE_REBUILD` below, on a store that predates the key. One * definition is what keeps the rebuilt shape and the created shape the same shape; * the gate then holds each adapter to including it. */ export declare const SCHEDULE_STATE_DDL = "\n CREATE TABLE IF NOT EXISTS _substrat_schedule_state (\n -- #1288: WHICH of the two families this row belongs to, named with the same two\n -- words _substrat_sweep_runs already records its entries under:\n -- * 'schedule' -- keyed by the operation (module/verb). last_run_at and\n -- last_status are when that operation RAN here and how it ended.\n -- * 'freshness' -- keyed freshness: (#1232). last_run_at and\n -- last_status are when the evaluator last RECORDED a verdict for that event\n -- type and what it was. Nothing ran; the row gates what the sweep records.\n kind TEXT NOT NULL,\n schedule_op TEXT NOT NULL,\n last_run_at TEXT,\n last_status TEXT,\n -- The key LEADS with kind, and that is the point of #1288 rather than a tidy-up.\n -- Until it did, the two families were told apart by the spelling of one column:\n -- a freshness key could never LOOK like an operation (an event type has passed\n -- contracts' eventType regex -- lowercase ns.verb, no colon, no slash), but the\n -- other direction was convention only, because scheduleSpec.operation is\n -- z.string().min(1). A module declaring a schedule literally named\n -- \"freshness:orders.placed\" shared the evaluator's row and nothing refused it:\n -- each write clobbered the other's verdict, and the sweep read back whichever\n -- ran last. With kind in the key those are two rows that cannot meet.\n PRIMARY KEY (kind, schedule_op)\n );\n"; /** * `_substrat_schedule_state`, rebuilt with its #1288 key on a store created before * it. Create-copy-drop-rename, because `kind` joins the PRIMARY KEY and SQLite * cannot widen a key in place — the same shape the directory's `ensureIdentityKey` * uses, and detected the same way (from `sqlite_master.sql`, which DO SQLite serves * and `PRAGMA` does not, so both adapters migrate by one strategy). * * The new table is `SCHEDULE_STATE_DDL` under a temporary name, so the rebuilt shape * cannot drift from the created one. * * The backfill derives `kind` from the `freshness:` prefix because that prefix IS how * the two families were told apart until now: every row the evaluator ever wrote * carries it, and no operation name in existence does. `substr(...) = 'freshness:'` * rather than `LIKE`, which is case-insensitive over ASCII in SQLite and would file a * schedule named `FRESHNESS:x` under the evaluator. * * Keys are copied VERBATIM, prefix included. What changes is which rows can coexist, * not what any row says — so a deployment rolled back to code that looks a freshness * key up under its old name still finds it. * * **Run these inside the adapter's transaction API**, which is what both callers do — * `db.transaction` on the pure side, `ctx.storage.transactionSync` on the DO side. * Un-wrapped, a stop between the CREATE and the DROP leaves the scratch table behind * and the NEXT wake dies on `table _substrat_schedule_state_new already exists`, which * is a scope that cannot open; a stop between the DROP and the RENAME is worse and * quieter, because the next wake's `CREATE TABLE IF NOT EXISTS` puts an EMPTY table * of the new shape in place, the detection below then reads it as already migrated, * and every copied row stays orphaned in the scratch table. Atomically, neither state * is reachable — which is why there is no recovery path here to go with them. * * The leading `DROP TABLE IF EXISTS` is belt to that braces, not the fix: it costs one * statement and makes the rebuild idempotent against a scratch table left by anything * below the transaction (a torn copy of the file, a restore that carried one). */ export declare const SCHEDULE_STATE_REBUILD: string; /** * Whether a store's `_substrat_schedule_state` already carries the #1288 key, read * off the `sql` column of `sqlite_master`. `false` means the rebuild is due. */ export declare function scheduleStateHasKind(tableSql: string): boolean; /** * What the recurring-schedule phase did in one pass (#383), summed across every * module's live scopes. `null` on `PlatformSweepReport` means the phase was * disabled or the host predates it — distinct from a report of all zeros, which is * "ran, nothing was due." */ export interface ScheduleSweepReport { /** Scopes `runDueSchedules` ran on (had ≥1 declared schedule). */ scopes: number; /** Due schedules whose operation ran successfully. */ fired: number; /** Schedules skipped this pass — still inside their cadence window. */ skipped: number; /** Due schedules whose operation failed — recorded, stepped over. */ failed: number; } /** What the #1172 phase did — see `reconcileScopeFn`. */ export interface ProvisionReconcileReport { /** Scopes whose bound version was ahead of their provisioned one this pass. */ behind: number; /** Of those, the ones whose reconcile succeeded and were marked. */ reconciled: number; /** Of those, the ones whose reconcile threw. They stay behind and retry next pass. */ failed: number; } export interface PlatformSweepReport { /** Active scopes `drainDue` ran on. */ scopesDrained: number; /** Drain outcomes summed across scopes. */ drainTotals: ExecutorDrainReport; /** Connections a sweeper ran for. */ connectionsSwept: number; /** Connections skipped — revoked, or their provider has no registered sweeper. */ connectionsSkipped: number; /** Expired forks reaped by `deleteSnapshot` this pass. */ snapshotsReaped: number; /** Long-archived primary scopes reaped by `reapScope` this pass (§4.4). */ archivedScopesReaped: number; /** Tenants past their grace window reaped this pass (§4.8). */ tenantsReaped: number; /** Platform-intent drain outcomes summed across scopes (platform-intents.md). */ platformRequestTotals: PlatformRequestDrainTotals; /** * Scopes whose provision was re-run because their bound version had moved past the one * it last ran against (#1172), or null when no `reconcileScopeFn` was supplied. * * Null and `{ reconciled: 0 }` are different facts, as everywhere else in this report: * the first is "nobody looked". */ provisionReconcile: ProvisionReconcileReport | null; /** * The migration-reconciliation phase's report (§5.3, #49), or null when the * phase was disabled or the host predates `migrateScope`. Note null vs a * report saying "everything migrated" are different facts — the first is * "nobody looked", which is exactly the unfalsifiable state the sweep exists * to end. */ migrations: MigrationSweepReport | null; /** * The recurring-schedule phase's report (#383), or null when the phase was * disabled or the host predates `registeredSchedules`. Null vs a report of zeros * are different facts — null is "nobody ran schedules", zeros is "ran, nothing * due" — the same distinction `migrations` draws. */ schedules: ScheduleSweepReport | null; /** * The access-log drain's report (K-24), or null when no sink was configured. Null vs * zeros is the same distinction `migrations` draws: null is "this deployment ships * nothing and its log grows by design", zeros is "shipped, nothing was waiting". */ accessLog: AccessLogSweepReport | null; /** What the event drain shipped this pass (#1334). Null when no sink is bound. */ eventDrain: EventDrainReport | null; /** Per-unit failures; the pass records and steps over each rather than aborting. */ errors: { kind: 'drain' | 'sweep' | 'gc' | 'reap' | 'reap-tenant' | 'migrate' | 'platform-request' | 'provision-reconcile' | 'schedule' | 'freshness' | 'access-log' | 'event-drain'; id: string; error: string; }[]; } /** One pass of the access-log drain (K-24, control-plane.md §4.4). */ /** What one pass drained, across every scope it reached. */ export interface EventDrainReport { /** Scopes that had at least one undrained event and were shipped. */ scopes: number; /** Events handed to the sink and confirmed durable. */ shipped: number; /** * Scopes whose batch filled the budget — more remains, and the next tick takes * it. Reported rather than looped, so one busy scope cannot starve the pass. */ incomplete: number; } export interface AccessLogSweepReport { /** Rows handed to the sink and confirmed durable. */ shipped: number; /** Rows stamped `drainedAt` as a result. Below `shipped` only on a re-run. */ marked: number; /** Drained rows deleted from the directory this pass. */ pruned: number; /** Where the batch landed, as the sink reported it. Null when nothing shipped. */ ref: string | null; } /** Platform-intent drain counts, summed across scopes in one pass. */ export interface PlatformRequestDrainTotals { /** Active scopes that had at least one intent drained. */ scopes: number; /** Intents seen across all scopes. */ drained: number; /** Executed successfully. */ done: number; /** Terminally failed (unknown kind, or a handler that gave up). */ failed: number; /** Left pending for a later drain (transient failure). */ pending: number; } /** * One pass of the platform's scheduled work: drain every active scope's due * executor deliveries, then reconcile every live connection against its provider. * * This is the SCHEDULER'S UNIT OF WORK — a Cloudflare cron, a Durable Object * alarm, or a node timer calls it; it holds no timer itself (see * `startPlatformSweeper`, and docs/architecture/scheduler.md). Both halves are code * that landed but had no caller: `drainDue` (the retry driver) and the * connectors' reconcile sweeps. * * Robust because a scheduled pass must be: bounded concurrency, so one slow * provider cannot delay the fleet, and a failure on any one scope or connection * is recorded in the report and stepped over — never allowed to sink the pass. * * Provider-agnostic: connections are discovered via `listConnections` and * dispatched to `sweepers[provider]`, so this imports no connector. A connection * whose provider has no sweeper, or that is revoked, is skipped (and counted), * not an error. */ export declare function runPlatformSweep(host: ScopeHost, options: PlatformSweepOptions): Promise; /** A running sweeper; `stop()` prevents the next pass and cancels the pending timer. */ export interface PlatformSweeperHandle { stop(): void; } export interface StartPlatformSweeperOptions extends PlatformSweepOptions { /** * Milliseconds between the END of one pass and the START of the next — a gap, * not a fixed rate. Rescheduling only after a pass settles means two passes can * never overlap, even when a pass runs longer than the interval. */ intervalMs: number; /** Observe each pass — for logging or a health metric. Never throws into the loop. */ onPass?: (outcome: PlatformSweepReport | { error: string; }) => void; /** Injected for tests; default to the runtime's timer. */ setTimer?: (cb: () => void, ms: number) => unknown; clearTimer?: (handle: unknown) => void; } /** * Drive `runPlatformSweep` on a self-rescheduling timer — the node/long-lived * runtime's trigger. (A Cloudflare deployment uses `scheduled()`/an alarm instead * and calls `runPlatformSweep` directly; both share the one unit of work.) * * Non-overlapping by construction: the next pass is scheduled only once the * current one settles, so a slow pass delays the next rather than stacking on it. */ export declare function startPlatformSweeper(host: ScopeHost, options: StartPlatformSweeperOptions): PlatformSweeperHandle; //# sourceMappingURL=platform-sweep.d.ts.map