/** * The directory's record of the schedule kill switch (#1674), shared by both adapters. * * The switch itself (`system-switch.ts`) lives in the scope's own storage: a `switch:off` * marker beside the module's grants, which is what the runner gates on. That is the right * place for the gate, and the wrong place for the only copy. A scope whose storage is wiped * and re-provisioned (#321/#332), or restored from a dump taken before the switch was pulled, * loses the marker, and the next reconcile seats the grants live again. The switch is then * silently on, on exactly the scopes an operator switched off. * * So the directory keeps one row per (tenant, scope, module): the position the switch was * last moved to, by whom, why, and when. It is the model `_substrat_connection_grants` set * (#592): the enforcement stays where it is checked, and the directory holds what a * reconcile re-asserts FROM. It also answers the fleet question the scope cannot: "which * scopes have a module switched off", without walking every scope's store. * * **OFF wins from either side, and a record never turns anything on.** * - A record of `off` and a scope that has lost its marker: the record wins, and * `HostAdmin.reassertSystemSwitches` switches it off again, after the seat, so the grants * the seat recreated are the ones OFF tombstones and a later ON gives back. * - A record of `on` (or none) and a live marker: the marker wins, and it stays off. * Restore is the lever (#1666), and a reconcile that could turn a kill switch back on * would be a second one. * * The admin log is still the history. This is only the current position. * * **The record and the scope follow the same call** (#2045). A switch call writes this record, then * moves the scope, and nothing makes the pair one unit, so two calls on one subject could interleave * and leave the two on different positions. Each row holds the operation id of the call that wrote * it, and a write from an older call writes nothing (`recordWriteSuperseded`). The scope refuses an * older move the same way (`SWITCH_FENCES_DDL`), and re-asserts move under the row's id * (`switchFencesOf`). Both sides therefore settle on the newest call's position. * * **Two kinds of switch, one record path** (#2029). The peer kill switch (#1706, * `revokeFromPeer`) moves the same marker for a `vertical:` subject, and loses it the * same ways: a wipe, a restore, a PITR rewind. So it is recorded the same way, in its own * table (`_substrat_peer_switches`, keyed by the calling vertical's slug), by the same * functions below, each told which `SwitchKind` it is writing. Its own table, not rows in the * module table: that table's key is (tenant, scope, module id), and a vertical slug can be * spelled exactly like a module id. */ import type { ListPage } from '@substrat-run/contracts'; import { type SwitchKind, type SwitchSql, type SwitchedOff, type SystemScheduleState } from './system-switch.js'; export type { SwitchKind }; /** * The table. Interpolated into both adapters' directory DDL, so `lint:spine-ddl` sees the * one spelling on each side. No CHECK on `position`: the drift gate does not compare * constraints, and the writers below are the only thing that sets it. */ export declare const SYSTEM_SWITCHES_DDL = "\n CREATE TABLE IF NOT EXISTS _substrat_system_switches (\n tenant_id TEXT NOT NULL,\n scope_id TEXT NOT NULL,\n module_id TEXT NOT NULL,\n -- 'on' or 'off'. A row exists only for a module some switch call actually held.\n position TEXT NOT NULL,\n actor TEXT NOT NULL,\n reason TEXT NOT NULL,\n -- The switch call's own id, the one its admin-log intent and outcome rows carry.\n -- A ULID, so it is also the fleet read's chronological keyset cursor.\n operation_id TEXT NOT NULL,\n switched_at TEXT NOT NULL,\n PRIMARY KEY (tenant_id, scope_id, module_id)\n );\n CREATE INDEX IF NOT EXISTS _substrat_system_switches_position\n ON _substrat_system_switches (position, operation_id);\n CREATE INDEX IF NOT EXISTS _substrat_system_switches_operation\n ON _substrat_system_switches (operation_id);\n"; /** * The peer switch's record (#2029): `SYSTEM_SWITCHES_DDL`'s shape, keyed by the calling * vertical's slug. No fleet read pages it yet, so it carries none of that read's indexes; every * read here names (tenant, scope), the primary key's prefix. */ export declare const PEER_SWITCHES_DDL = "\n CREATE TABLE IF NOT EXISTS _substrat_peer_switches (\n tenant_id TEXT NOT NULL,\n scope_id TEXT NOT NULL,\n -- The calling vertical's slug, as the target's peers declaration names it.\n vertical TEXT NOT NULL,\n position TEXT NOT NULL,\n actor TEXT NOT NULL,\n reason TEXT NOT NULL,\n operation_id TEXT NOT NULL,\n switched_at TEXT NOT NULL,\n PRIMARY KEY (tenant_id, scope_id, vertical)\n );\n"; /** * Everything that differs between the two kinds, in one place: where each is recorded, how its * switch calls and re-asserts are written to the admin log, and how a call that held nothing is * refused. Every function below reads this table; none branches on the kind itself. */ declare const RECORDS: { readonly system: { readonly table: "_substrat_system_switches"; readonly ddl: "\n CREATE TABLE IF NOT EXISTS _substrat_system_switches (\n tenant_id TEXT NOT NULL,\n scope_id TEXT NOT NULL,\n module_id TEXT NOT NULL,\n -- 'on' or 'off'. A row exists only for a module some switch call actually held.\n position TEXT NOT NULL,\n actor TEXT NOT NULL,\n reason TEXT NOT NULL,\n -- The switch call's own id, the one its admin-log intent and outcome rows carry.\n -- A ULID, so it is also the fleet read's chronological keyset cursor.\n operation_id TEXT NOT NULL,\n switched_at TEXT NOT NULL,\n PRIMARY KEY (tenant_id, scope_id, module_id)\n );\n CREATE INDEX IF NOT EXISTS _substrat_system_switches_position\n ON _substrat_system_switches (position, operation_id);\n CREATE INDEX IF NOT EXISTS _substrat_system_switches_operation\n ON _substrat_system_switches (operation_id);\n"; readonly key: 'module_id'; readonly subjectPrefix: 'system:'; readonly actions: readonly ['revokeFromSystem', 'restoreToSystem']; readonly reassertAction: 'reassertSystemSwitch'; /** The payload fields naming the subject and its position (`moduleId` + `schedules`). */ readonly payloadKey: 'moduleId'; readonly positionField: 'schedules'; readonly notFound: (scopeId: string, key: string, to: string) => string; }; readonly peer: { readonly table: "_substrat_peer_switches"; readonly ddl: "\n CREATE TABLE IF NOT EXISTS _substrat_peer_switches (\n tenant_id TEXT NOT NULL,\n scope_id TEXT NOT NULL,\n -- The calling vertical's slug, as the target's peers declaration names it.\n vertical TEXT NOT NULL,\n position TEXT NOT NULL,\n actor TEXT NOT NULL,\n reason TEXT NOT NULL,\n operation_id TEXT NOT NULL,\n switched_at TEXT NOT NULL,\n PRIMARY KEY (tenant_id, scope_id, vertical)\n );\n"; readonly key: 'vertical'; readonly subjectPrefix: "vertical:"; readonly actions: readonly ['revokeFromPeer', 'restoreToPeer']; readonly reassertAction: 'reassertPeerSwitch'; readonly payloadKey: 'vertical'; readonly positionField: 'calls'; readonly notFound: (scopeId: string, key: string, to: string) => string; }; }; /** Every admin-log action a switch CALL writes its intent and outcome under (#2089: the settle closes them). */ export declare const SWITCH_ACTIONS: readonly ["revokeFromSystem", "restoreToSystem", "revokeFromPeer", "restoreToPeer"]; export type SwitchAction = (typeof SWITCH_ACTIONS)[number]; /** Every kind, for the passes that walk them all (a scope's reap, a directory restore). */ export declare const SWITCH_KINDS: readonly SwitchKind[]; /** The admin-log action a switch call of this kind writes its intent and outcome rows under. */ export declare const switchActionOf: (kind: SwitchKind, to: 'on' | 'off') => (typeof RECORDS)[SwitchKind]['actions'][number]; /** The `not_found` a switch call that held nothing answers — one wording per kind, both adapters. */ export declare const switchNotFoundMessage: (kind: SwitchKind, scopeId: string, key: string, to: 'on' | 'off') => string; /** * #2045: the `conflict` a switch call answers when a newer call on the same subject got there first — * its record write or its move was refused by the fence, and nothing of it was written. */ export declare const switchSupersededMessage: (kind: SwitchKind, scopeId: string, key: string, to: 'on' | 'off') => string; /** The record table of one kind. */ export declare const switchesTableOf: (kind: SwitchKind) => string; /** * #2045: the subjects whose scope may not be where their record says. A WRITE-AHEAD intent * (Codex r3): the switch call's record write marks its subject owed in the same directory write, * BEFORE anything moves, and the mark is cleared only once the scope has confirmed a move under a * fence at least the call's own (`clearSwitchOwed`). So every way a move can go wrong — it throws, * its answer is lost, the RPC that would have cleared the mark fails, the caller dies — leaves the * mark set, and the next re-assert moves the scope to its record, in EITHER direction, under the * record's fence. Without a mark a re-assert keeps #1674's rule: a record never turns anything on. * * `operation_id` is the newest call that marked the subject. A confirmation under an older fence * clears nothing, so a newer call's mark outlives an older call's success. */ export declare const SWITCH_OWED_DDL = "\n CREATE TABLE IF NOT EXISTS _substrat_switch_owed (\n tenant_id TEXT NOT NULL,\n scope_id TEXT NOT NULL,\n -- 'system' or 'peer', and the module id or the peer's slug.\n kind TEXT NOT NULL,\n subject TEXT NOT NULL,\n -- The newest switch call that marked it: a ULID, so it orders by text.\n operation_id TEXT NOT NULL,\n PRIMARY KEY (tenant_id, scope_id, kind, subject)\n );\n"; /** #2045: mark one subject's scope owed a re-assert, by this switch call. Never moves a mark backwards. */ export declare function markSwitchOwed(db: SwitchSql, kind: SwitchKind, tenantId: string, scopeId: string, key: string, operationId: string): void; /** #2045: the subjects of one kind marked owed on one scope, each with the newest call that marked it. */ export declare function switchesOwedOf(db: SwitchSql, kind: SwitchKind, tenantId: string, scopeId: string): Map; /** * #2045: the scope confirmed a move of this subject under `fence` — its mark goes, unless a newer * call has marked it since (a compare-and-set on the mark's operation id). */ export declare function clearSwitchOwed(db: SwitchSql, kind: SwitchKind, tenantId: string, scopeId: string, key: string, fence: string): void; /** * #2045: does any subject on this scope still owe a re-assert? While one does, the scope's * reconcile receipt is not written (the control plane's `markScopeProvisioned`), so the sweep keeps * reconciling it until a re-assert has settled every mark. */ export declare function scopeOwesSwitch(db: SwitchSql, scopeId: string): boolean; /** Each kind's DDL, for a pass that creates a table with its backfill. */ export declare const switchesDdlOf: (kind: SwitchKind) => string; /** * The one-time backfill from the admin log: every switch moved before the record existed. * Without it, those are exactly the switches a wipe would still lose. * * It reads the admin-log shape the switch calls have written since they existed * (`revokeFromSystem` / `restoreToSystem` since #1666, `revokeFromPeer` / `restoreToPeer` * since #1706), two rows per call sharing `after.operationId`: an `intent` row carrying the * subject (`after.moduleId`, or `after.vertical` for a peer) and `after.reason`, then an * outcome row whose `after.phase` is `applied`, `refused` or `failed`. Only calls whose * outcome is `applied` count, because a refused call moved nothing and a failed one may not * have. Of those, only the LATEST per (tenant, scope, subject) is taken, by the intent's ULID * id. `at` is the intent's, the same instant the status read's who/why join reports. A key * whose calls include no applied OFF gets no row, even when its latest applied call is an ON: * history wrote no scope fence (#2045), so there is no fence for such a row to keep pace with, * and an ON-only key needs no record to re-assert. * * `INSERT OR IGNORE`, so a row the switch wrote itself is never overwritten by history. * The adapters run it only on the construction that creates the table, so it runs once. */ export declare function switchesBackfillSqlOf(kind: SwitchKind): string; /** * Does a dump carry this kind's record table? A directory restore builds the table either way, * and backfills it from the dump's own admin log only when the dump did not (#1898). Compared * without case, as SQLite resolves a table name. */ export declare function dumpCarriesSwitches(names: readonly string[], kind: SwitchKind): boolean; /** "Does this kind's record table exist yet?" — asked BEFORE the DDL, so the backfill runs once. */ export declare function switchesTableExists(db: SwitchSql, kind: SwitchKind): boolean; /** One record row as the directory holds it, with the scope's binding joined on. */ export interface SystemSwitchRecordRow { tenantId: string; scopeId: string; moduleId: string; vertical: string | null; position: 'on' | 'off'; actor: string; reason: string; operationId: string; at: string; } /** * Filter for the fleet read. Unset `position` means both; the HTTP route defaults it to * `off`. Paged by `operationId` (the exclusive cursor), oldest first unless `order: 'desc'`. */ export interface SystemSwitchRecordFilter extends ListPage { position?: 'on' | 'off'; tenantId?: string; scopeId?: string; moduleId?: string; vertical?: string; } /** * The fleet read (`GET /system-switches`): records, keyset-paged by `operation_id`. * * The key is the call that last moved the switch, so a row whose switch moves during a walk * is re-keyed to a newer id and jumps to the end of the order. Ascending (the default) never * skips a row for it; at worst it is seen twice, once per position. A DESCENDING walk can * miss a row that moved after the walk started, so use ascending for "every switch". */ export declare function listSystemSwitchRecords(db: SwitchSql, filter?: SystemSwitchRecordFilter): SystemSwitchRecordRow[]; /** * One scope's record rows of one kind, by subject (module id or peer slug) — what the per-scope * status read joins as `recorded`, and what a re-assert reverts a stale carry against. */ export declare function switchRecordsOf(db: SwitchSql, kind: SwitchKind, tenantId: string, scopeId: string): Map; /** * The per-scope status read's rows with the record joined on. Each module the scope * reports gets its recorded position (or null). A module the record holds that the scope * no longer reports at all is ADDED, as `ungranted`: that is the scope a wipe emptied, and * the drift is the whole reason to show it. Sorted by module id, as the scope's rows are. */ export declare function withRecorded(states: { moduleId: string; schedules: SystemScheduleState; }[], recorded: Map): { moduleId: string; schedules: SystemScheduleState; recorded: 'on' | 'off' | null; }[]; /** The subjects of one kind (module ids, or peer slugs) a reconcile must re-assert OFF on this scope. */ export declare function switchedOffOf(db: SwitchSql, kind: SwitchKind, tenantId: string, scopeId: string): string[]; /** * The scopes of one tenant the record holds a module OFF on — what a tenant-level * `grantToSystem` refuses on (#1743). A tenant tuple has no scope of its own to read a * marker from, and reaches every scope of the tenant, so the directory's record is the only * place the question can be asked in one unit with the write. The adapters run this and the * tenant tuple's write together, on the handle both live on. */ export declare function scopesSwitchedOffFor(db: SwitchSql, tenantId: string, moduleId: string): string[]; /** * Does the tenant hold a live TENANT-level grant for this subject (#1823, and #2030 for a peer)? * Asked of the directory, where the tenant tuple lives, and handed to the switch as * `tenantHeld`: a subject whose only authority on a scope is a tenant-level grant has nothing * in the scope's storage for the switch to find, and must still be switchable there. One * statement on both adapters' directories; the same "live" the evaluator judges a grant by * (unrevoked, unexpired at `now`). */ export declare function tenantHoldsGrant(db: SwitchSql, kind: SwitchKind, tenantId: string, key: string, now: string): boolean; /** Of `keys`, those the tenant holds a live tenant-level grant for (`tenantHoldsGrant`), in order. */ export declare function tenantHeldOf(db: SwitchSql, kind: SwitchKind, tenantId: string, keys: readonly string[], now: string): string[]; /** What one switch call writes into the record. `key` is the module id, or the peer's slug. */ export interface SwitchRecordWrite { kind: SwitchKind; tenantId: string; scopeId: string; key: string; actor: string; reason: string; operationId: string; at: string; } /** The row as it stood before a write, so a failed switch can put it back. */ export type SwitchRecordPrior = Pick | null; /** One record row's identity. */ type RecordKey = Pick; /** * What a re-assert did for one recorded-off subject — `HostAdmin.reassertSystemSwitches`' * answer: a module (`moduleId`) or, since #2029, a peer (`vertical`). */ export type SystemSwitchReassert = { held: boolean; changed: boolean; } & ({ moduleId: string; vertical?: never; } | { vertical: string; moduleId?: never; }); /** * What the deployment reports it switched off inside the reconcile's own unit (#1742), passed * to `HostAdmin.reassertSystemSwitches` so the move is audited here. The re-assert that * follows finds those subjects already off (`changed: false`), and without this the admin log * would stop showing that a wiped scope was put back off. */ export interface SystemSwitchReassertOptions { appliedInUnit?: readonly InUnitReport[]; } /** One in-unit move as a deployment reports it: a module's, or (#2029) a peer's. */ export type InUnitReport = Pick & ({ moduleId: string; vertical?: never; } | { vertical: string; moduleId?: never; }); /** * The fields an audit row names its subject and position with — the shape each kind's switch * calls have always written (`moduleId` + `schedules`, `vertical` + `calls`). */ export declare function switchAuditSubject(kind: SwitchKind, key: string, position: 'on' | 'off'): { moduleId: string; schedules: 'on' | 'off'; } | { vertical: string; calls: 'on' | 'off'; }; /** The admin-log action a re-assert of this kind writes its rows under. */ export declare const reassertActionOf: (kind: SwitchKind) => (typeof RECORDS)[SwitchKind]['reassertAction']; /** One re-assert answer entry: the subject by its kind's field, and what the move did. */ export declare const reassertEntry: (kind: SwitchKind, key: string, outcome: { held: boolean; changed: boolean; }) => SystemSwitchReassert; /** * #2045: the audit row (less its `operationId`) for a re-assert's ON that moved something — a * record of ON beside a scope that was off, converged to the record under its fence. */ export declare const reassertOnRow: (kind: SwitchKind, key: string, permissions: readonly string[]) => { moduleId: string; schedules: 'on' | 'off'; phase: 'applied'; changed: true; permissions: string[]; } | { vertical: string; calls: 'on' | 'off'; phase: 'applied'; changed: true; permissions: string[]; }; /** The audit row (less its `operationId`) for a re-assert's own OFF that moved something — both adapters. */ export declare const reassertOffRow: (kind: SwitchKind, key: string, permissions: readonly string[]) => { moduleId: string; schedules: 'on' | 'off'; phase: 'applied'; changed: true; permissions: string[]; } | { vertical: string; calls: 'on' | 'off'; phase: 'applied'; changed: true; permissions: string[]; }; /** * The re-assert's audit rows (less their `operationId`) for the in-unit moves of one kind: * those that changed something, on a subject the directory records `off` for this scope. * That filter is the point. The report comes from the deployment, and an audit row naming * a subject the record never switched off would be the deployment writing the platform's * log. One row per subject, first report wins. Built here so both adapters write one shape. */ export declare function inUnitMovesToAudit(kind: SwitchKind, recordedOff: readonly string[], applied: SystemSwitchReassertOptions['appliedInUnit'], /** Subjects this re-assert's stale-carry pass switched back on: their in-unit move is not credited. */ reverted?: ReadonlySet): (ReturnType & { phase: 'applied'; changed: true; permissions: string[]; inUnit: true; })[]; /** * The in-unit moves a STALE carry made, which the re-assert must undo (#1742 review): the * deployment switched a subject off because the list it was handed said so, but by the time * the re-assert reads the record, an operator's restore has moved it to `on`. The list was * read before the call and the ON landed in between. Left alone, the scope would stay off * while the record says on, and no later pass would put it back. * * Only a subject the record now holds `on`, so a revert only ever re-applies an operator's * own ON. It never turns on a subject whose record is `off` or missing, and so a report from * the deployment can never switch on anything the platform has not already restored. */ export declare function staleCarryReverts(kind: SwitchKind, recorded: ReadonlyMap, applied: SystemSwitchReassertOptions['appliedInUnit']): string[]; /** The audit row (less its `operationId`) for one stale-carry revert — both adapters write this shape. */ export declare function staleCarryRevertRow(kind: SwitchKind, key: string, outcome: { changed: boolean; permissions: readonly string[]; }): ReturnType & { phase: 'applied'; changed: boolean; permissions: string[]; staleCarry: true; }; /** * #2045: the record's half of the switch fence (`SWITCH_FENCES_DDL`). A write from a call OLDER than * the one the row holds writes nothing: a newer call has already recorded its position, and the * scope will refuse this call's move for the same reason. Both writes answer the prior row, and the * caller reads this off it, with the same test (`recordWriteSuperseded`). */ declare const supersededBy: (prior: SwitchRecordPrior, row: Pick) => boolean; /** #2045: did this record write find a newer call's row, and so write nothing? */ export declare const recordWriteSuperseded: typeof supersededBy; /** * #2045: the operation id each of one scope's record rows of one kind holds, by subject — the fence a * re-assert or a carry moves the scope with, so a scope moved since by a newer call stays as that * call put it. */ export declare function switchFencesOf(db: SwitchSql, kind: SwitchKind, tenantId: string, scopeId: string): Map; /** * #2045: each record row's position AND operation id, by subject, in ONE read. A caller that moves * the scope to a row's position under the row's fence must take both from the same instant: read * apart, a row that moves between them pairs one call's position with another call's fence (an OFF * carried under the ON's fence applies, since the scope holds that very fence), and a row the undo * deleted between them is moved with no fence at all, which the scope applies unconditionally. */ export declare function switchRecordStatesOf(db: SwitchSql, kind: SwitchKind, tenantId: string, scopeId: string): Map; /** #2045: the tuple subject a record key names (`system:`, `vertical:`) — how a carry keys its fences. */ export declare const switchSubjectOf: (kind: SwitchKind, key: string) => string; /** * OFF's write, made BEFORE the scope's switch moves (#1823), and undone by * `restoreSwitchRecord` when the move throws or holds nothing. An upsert: a repeat OFF * refreshes the actor, reason and time, as the status read reports the latest reason. * Answers the row as it was. * * Before, because the other order left a window: between the scope moving and this write, a * tenant-level `grantToSystem` read no `off` row and was accepted (#1743's refusal reads this * table, in the unit that writes the tenant tuple). Written first, there is no instant at * which the scope is off and the record is not. This order fails toward a record of `off` * beside a scope still on, until the undo runs — the direction OFF wins anyway, and the one a * kill switch should fail in. Still never LEFT behind for a call that held nothing: a row * saying `off` for a subject the scope never had would switch it off on the day it is * installed (the kernel's `held` check, for the same reason). */ export declare function recordSwitchedOff(db: SwitchSql, row: SwitchRecordWrite): SwitchRecordPrior; /** * ON's write, made BEFORE the scope's switch moves. Before, because the other order fails * unsafely: a scope switched on whose record write then failed would still read `off`, and the * next reconcile would switch the operator's restore back off. Answers the row as it was, for * `restoreSwitchRecord`. * * #2045: an upsert, like OFF's. An ON with no row before it writes one, because its move stores * its id in the scope's fence: without a row carrying the same id, an OLDER OFF arriving after * it would find no row to be refused by, record itself, and leave the directory behind the scope * (Codex r2). The directory's fence is never behind the scope's. An ON that holds nothing still * leaves no row: its undo removes the one it wrote. */ export declare function recordSwitchedOn(db: SwitchSql, row: SwitchRecordWrite): SwitchRecordPrior; /** * Put a row back as `recordSwitchedOn` or `recordSwitchedOff` found it — the call that wrote * it threw, or held nothing. A row that did not exist is removed again. A compare-and-set on * the call's own `operationId`: if another switch call has written the row since, the row is * left as that call wrote it, so an undo can never clobber a newer position. */ export declare function restoreSwitchRecord(db: SwitchSql, call: RecordKey & { operationId: string; }, prior: SwitchRecordPrior): void; /** * Forget one scope's switch records, of every kind — the scope's directory row is going too (a * reaped preview or fork, a reaped scope). Without this the fleet read keeps listing a switch on * a scope that no longer exists. The admin log keeps the history, as it keeps the scope's. */ export declare function forgetSwitchesOf(db: SwitchSql, scopeId: string): void; //# sourceMappingURL=system-switch-record.d.ts.map