/** * Closing an audited change whose outcome was never written (#2064), shared by both adapters. * * The owner hand-over (#1665) and dashboard member management (#1150) audit a change made in the * vertical's own deployment in two rows paired by `operationId`: an `intent`, then `applied`, * `refused` or `failed`. The schedule and peer kill switches (#1666, #1706; #2089) audit the * scope's move the same way, from inside each adapter, and so does the replay lever * (`moveImportCursor`, #2114), whose rows carry its `replayId` as their `operationId`. The two * stores are separate, so an intent can be left with no outcome: its outcome write failed (`recordAuditOutcome` logs it), or the * request died between the two. The control plane's scheduled * pass finds such an intent and asks the host to settle it through * `HostAdmin.settleUnrecordedOutcome`. * * **The settle is one transaction, and it claims the outcome only if none exists.** It reads * the intent by id, checks there is no outcome row for the operation, then writes the `unknown` * row and the ops-failure row that brings it to the staff digest. Two passes that race settle * once. An outcome that lands before the settle runs wins, and the settle writes nothing. * Because the ops-failure row is written in the same unit, an `unknown` row cannot exist without * the digest hearing of it. * * **A real outcome supersedes `unknown`, by priority, not by order.** The log is append-only, so * a real outcome that lands after the settle is still recorded. Every reader resolves an * operation by `effectiveOutcomes`: a real outcome (`applied`, `refused`, `failed`) beats * `unknown` whichever row was written first, since the two writers' ids and clocks need not * agree. Two real outcomes for one operation break the audit's invariant, so they resolve to * `conflicting`, which is reported and never guessed past. The control plane bounds the vertical call well inside the * settle's grace window, so a live request cannot normally be overtaken this way. The rule is * stated for the case where one is. */ import { type CapabilityRecord } from '@substrat-run/contracts'; import type { OpsFailureInput } from './scope-host.js'; /** * The admin actions written intent-then-outcome, which a settle closes: the two the control plane * writes around a vertical call, (#2089) the schedule and peer kill switches, which each adapter * writes around the scope's move, and (#2114) the replay lever, written the same way around the * watermark's move. A lever row written before #2114 carries only `replayId`, no `operationId`, * so every reader passes it over as it passes over any row without one. (#1686) The operator's * capability revoke is written the same way around the revoke in the scope's store * (`auditedCapabilityRevoke`), which on the shared control plane is another deployment's. */ export declare const AUDITED_CHANGE_ACTIONS: readonly ["transferOwner", "manageScopeMember", "setPeerBinding", "revokeFromSystem", "restoreToSystem", "revokeFromPeer", "restoreToPeer", "moveImportCursor", "revokeCapability"]; export type AuditedChangeAction = (typeof AUDITED_CHANGE_ACTIONS)[number]; /** The structured line an outcome row that could not be written leaves, keyed so a log search finds every one. */ export declare const UNRECORDED_OUTCOME_LOG = "audit-outcome-unrecorded"; /** Where an unwritten outcome is reported: the host's error log (the kernel has none of its own). */ export type AuditLogError = (message: string, fields: Record) => void; /** * Write one outcome row of an audited change (#2064, #2089 for the kill switches), never * swallowing its failure. Answers null when the row landed. When it did not, the failure is * logged as `UNRECORDED_OUTCOME_LOG` with the flow, the operation id and the phase, and its * message is the answer, for the caller to surface: a `refused`/`failed` caller still answers * with its own error (so a refusal never reads as retryable), and an `applied` caller answers * success with `auditWarningOf`. The intent left without an outcome is closed as `unknown` by * the control plane's scheduled settle either way. */ export declare function recordAuditOutcome(write: () => unknown, line: { flow: string; operationId: string; phase: string; }, logError: AuditLogError): Promise; /** * The platform's capability revoke, audited intent-then-outcome (#1686) — the one body both * adapters run, so the rows cannot differ by adapter. The intent row lands before anything is * revoked, every attempt; then exactly one outcome, or none: * * - `applied`, with the record as it stood as the row's `before`, when the store answers the * record of the capability asked for; * - `refused` when the store holds no such capability (the call then throws `not_found`), or when * the revoke failed in a way that proves nothing changed (`provesNothingChanged`: a refusal, or * a deployment without the route); * - none when the answer is lost, or names another capability: the revoke may have landed, so the * intent is left for the control plane's scheduled settle to close as `unknown`, and the call * throws. Never `refused`, which would read as "the link still opens, retry". * * An outcome row that cannot be written is logged (`recordAuditOutcome`) and the intent settles * `unknown` the same way, so a revoke that landed is never left with no admin record. */ export declare function auditedCapabilityRevoke(input: { capabilityId: string; scopeId: string; /** Write one admin row of the operation: `before` is the record as it stood, on `applied` only. */ record: (before: CapabilityRecord | null, after: Record) => unknown; /** The revoke in the scope's store: the record as it stood, or null when it holds no such capability. */ revoke: () => Promise; logError: AuditLogError; }): Promise; /** The `auditWarning` a change that went through answers when its `applied` row could not be written. */ export declare const auditWarningOf: (what: string, unrecorded: string) => string; /** The intent a settle reads by id, inside its transaction. */ export declare const SETTLE_INTENT_SQL = "SELECT id, action, tenant_id, scope_id, vertical, after FROM _substrat_admin_log WHERE id = ?"; /** * Both statements below write `+action`, so the action column cannot drive an index choice: an * operation's few rows are reached through `_substrat_admin_log_operation` (the kernel's * admin-log index list), never by walking every row of an action. A planner left to choose takes * `(action, id)`, which reads the whole history of the action. */ /** * Whether the operation already has an outcome: a non-intent row of the SAME operation, its whole * identity compared column by column. The operation id reaches `_substrat_admin_log_operation`. * The action, tenant and scope are compared with `IS`, so a null tenant or scope matches only * null and never acts as a wildcard, and written `+column` so that the operation id is the only * index the statement can use: left to choose, the planner took `(action, id)` once, and * `(tenant_id, id)` or `(scope_id, id)` would read as much. No * value is serialized to compare, so nothing depends on two encoders agreeing. Never by row * order: the intent, a real outcome and a settle's `unknown` are stamped by different writers * whose ids and clocks need not agree. */ export declare const SETTLE_OUTCOME_SQL = "SELECT 1 AS present FROM _substrat_admin_log\n WHERE json_extract(after, '$.operationId') = ?\n AND json_extract(after, '$.operationId') IS NOT NULL\n AND +action IS ?\n AND +tenant_id IS ?\n AND +scope_id IS ?\n AND json_extract(after, '$.phase') <> 'intent'\n LIMIT 1"; /** `SETTLE_OUTCOME_SQL`'s parameters for one operation, every part of its identity in order. */ export declare const settleOutcomeParamsOf: (operation: AuditedOperationRef) => [string, string, string | null, string | null]; /** How many operation ids one batched read binds: well under a Durable Object's 100 parameters. */ export declare const AUDITED_OPERATIONS_BATCH = 50; /** * Every row of the given operations, by the operation-id index. One statement per batch of * `AUDITED_OPERATIONS_BATCH` ids. The caller narrows the rows to the action, tenant and scope it * asked about. */ export declare function auditedOperationsSql(count: number): string; /** One row as `auditedOperationsSql` reads it. */ export interface AuditedOperationSqlRow { id: string; action: string; tenant_id: string | null; scope_id: string | null; operation_id: string; phase: string | null; } /** One row of an audited operation, as `HostAdmin.auditedOperations` answers it. */ export interface AuditedOperationRow { id: string; action: string; tenantId: string | null; scopeId: string | null; operationId: string; phase: string | null; } /** The operations a reader asks about: an operation is its action, its id and its scope. */ export interface AuditedOperationRef { action: string; operationId: string; tenantId: string | null; scopeId: string | null; } /** * Run `auditedOperationsSql` over `refs` in batches through `all`, and keep only the rows of * the exact operations asked about. An operation id from another action or scope is not one. */ export declare function readAuditedOperations(all: (sql: string, params: string[]) => AuditedOperationSqlRow[], refs: readonly AuditedOperationRef[]): AuditedOperationRow[]; /** * An audited operation's identity: its action, its operation id AND the tenant and scope it was * made in. The one key every grouping, lookup and resolution uses, built only here. An id alone * is not an operation: the same id in two scopes is two operations, never a conflict. */ export type OperationKey = string & { readonly __operationKey: unique symbol; }; export declare const operationKeyOf: (r: AuditedOperationRef) => OperationKey; /** One row as `SETTLE_INTENT_SQL` reads it. */ export interface SettleIntentRow { id: string; action: string; tenant_id: string | null; scope_id: string | null; vertical: string | null; after: string | null; } /** What a settle writes: the admin row's target and payload, and the ops-failure row. */ export interface UnknownOutcome { action: AuditedChangeAction; operationId: string; /** The operation the intent belongs to, the settle check's whole key. */ operation: AuditedOperationRef; target: { tenantId: string | null; scopeId: string | null; vertical: string | null; }; after: Record; failure: Omit; } /** * The `unknown` outcome for one intent row: the intent's own fields with `phase: 'unknown'` and * `error` saying why, parsed by the same schema the flow writes its rows with. Throws `not_found` * when the row is absent, or is not the intent of an audited change. */ export declare function unknownOutcomeOf(row: SettleIntentRow | undefined, intentId: string, error: string): UnknownOutcome; /** The phases an audited change's rows carry. */ export type AuditedPhase = 'intent' | 'applied' | 'refused' | 'failed' | 'unknown'; /** Where an operation stands, by priority over its rows (see the module header). */ export interface EffectiveOutcome { /** `conflicting`: more than one real outcome row, which the audit must never hold. */ outcome: 'applied' | 'refused' | 'failed' | 'unknown' | 'conflicting'; /** The real outcome phases found, in no particular order: one, or several when conflicting. */ real: string[]; } /** * Each audited operation's effective outcome among `rows`, keyed by `operationKeyOf`. A real * outcome beats `unknown` regardless of which was written first. Two or more real outcome rows * are `conflicting`. An operation with only an intent has no entry, so it is pending. Row ids * and timestamps decide nothing. */ export declare function effectiveOutcomes(rows: readonly (AuditedOperationRef & { phase: string | null; })[]): Map; /** Whether a row is an outcome its operation's effective outcome replaced: an `unknown` beaten by a real one. */ export declare function isSupersededOutcome(phase: string | null, effective: EffectiveOutcome | undefined): boolean; //# sourceMappingURL=audit-outcome.d.ts.map