@substrat-run/adapter-cloudflare 0.139.0 → 0.141.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,9 @@
1
1
  import { DurableObject } from 'cloudflare:workers';
2
- import { membershipFencesBackfillSql, MEMBERSHIP_FENCES_DDL, MEMBERSHIP_FENCES_TABLE, membershipFencesTableExists, MEMBERSHIP_FENCE_SINCE_SQL, RAISE_MEMBERSHIP_FENCE_SQL, memberAddedAudit, orgChangeBound, tenantCoverage, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, FINDINGS_DDL, createFindingRule, findingOfOpsFailure, findingOfSweepRun, pruneFindings, listFindingRules, listFindings, observeFinding, revokeFindingRule, setFindingStatus, assertRowLimit, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, PEER_SWITCHES_DDL, SWITCH_OWED_DDL, clearSwitchOwed, markSwitchOwed, scopeOwesSwitch, switchesOwedOf, SWITCH_KINDS, SYSTEM_SWITCHES_DDL, forgetSwitchesOf, listSystemSwitchRecords, recordSwitchedOff, recordSwitchedOn, recordWriteSuperseded, restoreSwitchRecord, scopesSwitchedOffFor, switchRecordStatesOf, switchRecordsOf, switchesBackfillSqlOf, switchesDdlOf, switchesTableExists, switchesTableOf, tenantHeldOf, VERSION_MIGRATIONS_DDL, splitVersionMigrationsBatch, versionMigrationsOf, versionsAwaitSplit, writeVersionMigrations, MODEL_USAGE_RETENTION_DAYS, DO_SQL_LIMITS, ulid, isPrimaryScopeRow, resolveVerticalInstanceFrom, LEGACY_SCOPE_ROWS_BACKFILL, loadDirectoryDump, redactSubjectDirectoryText, ISSUE_EXEMPLAR_OWNER_BACKFILL_SQL, issueExemplarOwner, } from '@substrat-run/kernel';
2
+ import { membershipFencesBackfillSql, MEMBERSHIP_FENCES_DDL, MEMBERSHIP_FENCES_TABLE, membershipFencesTableExists, MEMBERSHIP_FENCE_SINCE_SQL, RAISE_MEMBERSHIP_FENCE_SQL, memberAddedAudit, orgChangeBound, tenantCoverage, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, FINDINGS_DDL, CONNECT_LINKS_DDL, consumeConnectLinkRow, insertConnectLink as insertConnectLinkRow, listConnectLinks as listConnectLinkRows, readConnectLink as readConnectLinkRow, restoreConnectLinkRow, revokeConnectLinkRow, createFindingRule, findingOfOpsFailure, findingOfSweepRun, pruneFindings, listFindingRules, listFindings, observeFinding, revokeFindingRule, setFindingStatus, assertRowLimit, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, PEER_SWITCHES_DDL, PEER_BINDINGS_DDL, SWITCH_OWED_DDL, clearSwitchOwed, markSwitchOwed, scopeOwesSwitch, switchesOwedOf, SWITCH_KINDS, SYSTEM_SWITCHES_DDL, forgetSwitchesOf, listSystemSwitchRecords, recordSwitchedOff, recordSwitchedOn, recordWriteSuperseded, restoreSwitchRecord, scopesSwitchedOffFor, switchRecordStatesOf, switchRecordsOf, switchesBackfillSqlOf, switchesDdlOf, switchesTableExists, switchesTableOf, tenantHeldOf, VERSION_MIGRATIONS_DDL, splitVersionMigrationsBatch, versionMigrationsOf, versionsAwaitSplit, writeVersionMigrations, MODEL_USAGE_RETENTION_DAYS, DO_SQL_LIMITS, ulid, isPrimaryScopeRow, resolveVerticalInstanceFrom, resolvePeerInstanceFrom, LEGACY_SCOPE_ROWS_BACKFILL, loadDirectoryDump, redactSubjectDirectoryText, ISSUE_EXEMPLAR_OWNER_BACKFILL_SQL, issueExemplarOwner, SETTLE_INTENT_SQL, SETTLE_OUTCOME_SQL, settleOutcomeParamsOf, unknownOutcomeOf, ADMIN_LOG_INDEX_DDL, ADMIN_LOG_INDEXES_SQL, readAuditedOperations, } from '@substrat-run/kernel';
3
3
  import { replyOf } from './do-reply.js';
4
- import { splitSqlStatements, switchSqlOver } from './scope-do.js';
4
+ import { switchSqlOver } from './scope-do.js';
5
+ import { blankSqlComments, executableSqlStatements } from '@substrat-run/kernel';
6
+ import { COPY_CLAIM_SQL, COPY_EXPIRED_SQL, COPY_MOVE_CONFIRM_SQL, COPY_MOVE_LIVE_PREDICATE, SCOPE_COPY_LEASE_MS, SCOPE_SCRIPT_COPY_COLUMNS, copyMoveConfirmParams, copyMoveLiveParams, } from '@substrat-run/kernel';
5
7
  import { doBuiltColumnsOf, doRedactionSql } from './sql.js';
6
8
  import { assertReplayableDump, opsFailureFingerprint, ROUTE_SCOPE_HOSTNAMES_MAX, SCOPE_GATE_REASONS, substratError } from '@substrat-run/contracts';
7
9
  /**
@@ -138,6 +140,10 @@ const DIRECTORY_DDL = `
138
140
  status TEXT NOT NULL DEFAULT 'active',
139
141
  schema_version TEXT NOT NULL DEFAULT '0',
140
142
  vertical_version_id TEXT,
143
+ erasure_epoch INTEGER,
144
+ -- #1722: set once a reap has started draining this scope's script copies, and never
145
+ -- cleared: only deleting the row ends it. While set, no copy move is recorded or bound.
146
+ reap_claimed_at TEXT,
141
147
  -- Last FAILED migration attempt (§5.3). All null / 0 = healthy. Written on the
142
148
  -- failure path so a scope that fails closed stops rendering as active, and
143
149
  -- cleared on the next success. See ScopeDO.applyPendingMigrations.
@@ -159,6 +165,26 @@ const DIRECTORY_DDL = `
159
165
  serving_ref TEXT,
160
166
  created_at TEXT NOT NULL
161
167
  );
168
+ -- #1722: durable inventory of per-script copies. A fresh directory has no rows;
169
+ -- the historical audit-log backfill runs separately, after this additive migration.
170
+ CREATE TABLE IF NOT EXISTS scope_script_copies (
171
+ tenant_id TEXT NOT NULL,
172
+ scope_id TEXT NOT NULL,
173
+ script_ref TEXT NOT NULL,
174
+ move_id TEXT NOT NULL,
175
+ state TEXT NOT NULL,
176
+ load_stamp TEXT,
177
+ revision TEXT,
178
+ last_attempt_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
179
+ -- 'source' | 'destination'. A pending entry belongs to its move until lease_until; a
180
+ -- sweep that claims it after that sets lease_owner (kernel scope-copy-ledger.ts).
181
+ role TEXT,
182
+ lease_until TEXT,
183
+ lease_owner TEXT,
184
+ PRIMARY KEY (tenant_id, scope_id, script_ref, move_id)
185
+ );
186
+ CREATE INDEX IF NOT EXISTS scope_script_copies_state ON scope_script_copies (state, scope_id);
187
+ ${PEER_BINDINGS_DDL}
162
188
  CREATE TABLE IF NOT EXISTS hostnames (
163
189
  hostname TEXT PRIMARY KEY,
164
190
  tenant_id TEXT NOT NULL,
@@ -416,6 +442,9 @@ const DIRECTORY_DDL = `
416
442
  updated_at TEXT NOT NULL,
417
443
  PRIMARY KEY (connection_id, state_key)
418
444
  );
445
+ -- A vertical's mailed connect links (connections.md §3.5.4) — kernel-owned DDL, so
446
+ -- both adapters build the same table.
447
+ ${CONNECT_LINKS_DDL}
419
448
  CREATE TABLE IF NOT EXISTS _substrat_identities (
420
449
  provider TEXT NOT NULL,
421
450
  external_id TEXT NOT NULL,
@@ -478,16 +507,12 @@ const DIRECTORY_DDL = `
478
507
  on_behalf_of TEXT,
479
508
  at TEXT NOT NULL
480
509
  );
481
- -- Read-path indexes for the console (control-plane.md §4.5). The admin log is
482
- -- append-only and only grows, so every filter it offers needs one.
510
+ -- Every admin-log index, from the kernel's one list (#2064), which the legacy
511
+ -- rebuild runs again after its rename.
483
512
  -- NOTE: keep every comment in this DDL free of semicolons. The constructor
484
513
  -- splits this string on the statement separator, and a semicolon inside a
485
514
  -- comment strands a comment-only fragment that execs as "no statement".
486
- CREATE INDEX IF NOT EXISTS _substrat_admin_log_tenant ON _substrat_admin_log (tenant_id, id);
487
- CREATE INDEX IF NOT EXISTS _substrat_admin_log_scope ON _substrat_admin_log (scope_id, id);
488
- CREATE INDEX IF NOT EXISTS _substrat_admin_log_actor ON _substrat_admin_log (actor, id);
489
- CREATE INDEX IF NOT EXISTS _substrat_admin_log_action ON _substrat_admin_log (action, id);
490
- CREATE INDEX IF NOT EXISTS _substrat_admin_log_at ON _substrat_admin_log (at);
515
+ ${ADMIN_LOG_INDEXES_SQL}
491
516
  -- Operational failures (#559) - what the platform could NOT do, and why. NOT the
492
517
  -- admin log (that is the never-swept compliance witness of successful mutations)
493
518
  -- but retention-bounded telemetry, pruned on write after OPS_FAILURE_RETENTION_DAYS.
@@ -652,6 +677,8 @@ const SCOPE_COLUMNS_ADDED = [
652
677
  'name TEXT',
653
678
  'vertical TEXT',
654
679
  'vertical_version_id TEXT',
680
+ 'erasure_epoch INTEGER',
681
+ 'reap_claimed_at TEXT',
655
682
  'provisioned_version_id TEXT',
656
683
  'migration_failed_version TEXT',
657
684
  'migration_error TEXT',
@@ -674,8 +701,8 @@ const SCOPE_COLUMNS_ADDED = [
674
701
  * fragment and the DDL had parted company (its test holds it empty).
675
702
  */
676
703
  export function planDirectoryDdl(ddl) {
677
- const all = splitSqlStatements(ddl);
678
- const afterColumns = splitSqlStatements(VERSION_MIGRATIONS_DDL);
704
+ const all = executableSqlStatements(ddl);
705
+ const afterColumns = executableSqlStatements(VERSION_MIGRATIONS_DDL);
679
706
  const held = new Set(afterColumns);
680
707
  return {
681
708
  loop: all.filter((stmt) => !held.has(stmt)),
@@ -825,7 +852,7 @@ export class ControlPlaneDO extends DurableObject {
825
852
  this.buildDirectorySchema({ holdSwitchRecords: newRecords, holdFences: fencesAreNew });
826
853
  for (const kind of newRecords) {
827
854
  this.ctx.storage.transactionSync(() => {
828
- for (const stmt of splitSqlStatements(switchesDdlOf(kind)))
855
+ for (const stmt of executableSqlStatements(switchesDdlOf(kind)))
829
856
  this.sql.exec(stmt);
830
857
  this.sql.exec(switchesBackfillSqlOf(kind));
831
858
  });
@@ -833,7 +860,7 @@ export class ControlPlaneDO extends DurableObject {
833
860
  // #1184: the removal fence and its backfill from the admin log, together, the same way.
834
861
  if (fencesAreNew) {
835
862
  this.ctx.storage.transactionSync(() => {
836
- for (const stmt of splitSqlStatements(MEMBERSHIP_FENCES_DDL))
863
+ for (const stmt of executableSqlStatements(MEMBERSHIP_FENCES_DDL))
837
864
  this.sql.exec(stmt);
838
865
  // Stamped now, inside this unit (`membershipFencesBackfillSql` says why).
839
866
  this.sql.exec(membershipFencesBackfillSql(new Date().toISOString()));
@@ -907,7 +934,7 @@ export class ControlPlaneDO extends DurableObject {
907
934
  rebuildAtomically(statements) {
908
935
  this.ctx.storage.transactionSync(() => {
909
936
  for (const stmt of statements)
910
- this.sql.exec(stmt);
937
+ this.sql.exec(blankSqlComments(stmt));
911
938
  });
912
939
  }
913
940
  /**
@@ -972,6 +999,8 @@ export class ControlPlaneDO extends DurableObject {
972
999
  FROM _substrat_admin_log`,
973
1000
  'DROP TABLE _substrat_admin_log',
974
1001
  'ALTER TABLE _substrat_admin_log_new RENAME TO _substrat_admin_log',
1002
+ // The DROP took every index on the table: rebuild them from the kernel's list (#2064).
1003
+ ...ADMIN_LOG_INDEX_DDL,
975
1004
  ]);
976
1005
  }
977
1006
  /**
@@ -1292,6 +1321,7 @@ export class ControlPlaneDO extends DurableObject {
1292
1321
  '_substrat_system_switches', // #1674: the schedule switch's record, per scope
1293
1322
  '_substrat_membership_fences', // #1184: the latest removal, per principal
1294
1323
  '_substrat_peer_switches', // #2029: the peer switch's record, per scope
1324
+ 'peer_bindings', // #1720: a tenant's explicit caller-to-target choices
1295
1325
  '_substrat_switch_owed', // #2045: subjects owed a re-assert, per scope
1296
1326
  '_substrat_findings', // #1748: the tenant's findings
1297
1327
  '_substrat_finding_rules', // #1748: the tenant's suppress rules
@@ -1654,6 +1684,9 @@ export class ControlPlaneDO extends DurableObject {
1654
1684
  else {
1655
1685
  this.sql.exec('UPDATE scopes SET status = ? WHERE scope_id = ?', to, scopeId);
1656
1686
  }
1687
+ if (to === 'archived' || to === 'reaped') {
1688
+ this.sql.exec('UPDATE peer_bindings SET invalidated = 1 WHERE tenant_id = ? AND target_scope_id = ?', tenantId, scopeId);
1689
+ }
1657
1690
  if (to === 'reaped') {
1658
1691
  forgetSwitchesOf(this.kernelSql, scopeId);
1659
1692
  this.forgetLifecycleDeliveries('scope_id = ?', scopeId);
@@ -1761,21 +1794,74 @@ export class ControlPlaneDO extends DurableObject {
1761
1794
  forkedFrom: r.forked_from ?? null,
1762
1795
  })), tenantId, vertical);
1763
1796
  }
1797
+ /** Binding is read at call execution, so a queued invoke follows the current explicit choice. */
1798
+ resolvePeerInstance(tenantId, callerScopeId, vertical) {
1799
+ const rows = this.sql.exec(`SELECT scope_id, tenant_id, vertical, status, kind, forked_from FROM scopes
1800
+ WHERE tenant_id = ? AND vertical = ?`, tenantId, vertical).toArray();
1801
+ const binding = this.sql.exec('SELECT target_scope_id, invalidated FROM peer_bindings WHERE tenant_id = ? AND caller_scope_id = ? AND vertical = ?', tenantId, callerScopeId, vertical).toArray()[0];
1802
+ return resolvePeerInstanceFrom(rows.map((r) => ({
1803
+ id: r.scope_id,
1804
+ tenantId: r.tenant_id,
1805
+ vertical: r.vertical,
1806
+ status: r.status,
1807
+ kind: r.kind ?? '',
1808
+ forkedFrom: r.forked_from,
1809
+ })), tenantId, vertical, binding?.target_scope_id ?? null, binding?.invalidated === 1);
1810
+ }
1811
+ peerBinding(tenantId, callerScopeId, vertical) {
1812
+ return this.sql.exec('SELECT target_scope_id, invalidated FROM peer_bindings WHERE tenant_id = ? AND caller_scope_id = ? AND vertical = ?', tenantId, callerScopeId, vertical).toArray()[0];
1813
+ }
1814
+ peerBindings(tenantId, callerScopeId) {
1815
+ return this.sql.exec('SELECT vertical, target_scope_id, invalidated FROM peer_bindings WHERE tenant_id = ? AND caller_scope_id = ? ORDER BY vertical', tenantId, callerScopeId).toArray();
1816
+ }
1817
+ /** Directory write and endpoint checks share this DO's serialized unit. */
1818
+ setPeerBinding(tenantId, callerScopeId, vertical, targetScopeId) {
1819
+ const caller = this.sql.exec('SELECT tenant_id, status, kind, forked_from FROM scopes WHERE scope_id = ?', callerScopeId).toArray()[0];
1820
+ if (!caller || caller.tenant_id !== tenantId || caller.status !== 'active' || !isPrimaryScopeRow(caller)) {
1821
+ return { ok: false, code: 'not_found', message: 'calling app is not a live primary scope in this tenant' };
1822
+ }
1823
+ if (targetScopeId !== null) {
1824
+ const target = this.sql.exec('SELECT tenant_id, vertical, status, kind, forked_from FROM scopes WHERE scope_id = ?', targetScopeId).toArray()[0];
1825
+ if (!target || target.tenant_id !== tenantId || target.vertical !== vertical || target.status !== 'active' || !isPrimaryScopeRow(target)) {
1826
+ return { ok: false, code: 'not_found', message: 'target is not a live primary instance of this vertical in this tenant' };
1827
+ }
1828
+ }
1829
+ const before = this.peerBinding(tenantId, callerScopeId, vertical);
1830
+ const previous = before?.target_scope_id ?? null;
1831
+ if (previous === targetScopeId && before?.invalidated !== 1)
1832
+ return { ok: true, changed: false, previous };
1833
+ if (targetScopeId === null) {
1834
+ this.sql.exec('DELETE FROM peer_bindings WHERE tenant_id = ? AND caller_scope_id = ? AND vertical = ?', tenantId, callerScopeId, vertical);
1835
+ }
1836
+ else {
1837
+ this.sql.exec(`INSERT INTO peer_bindings (tenant_id, caller_scope_id, vertical, target_scope_id)
1838
+ VALUES (?, ?, ?, ?) ON CONFLICT (tenant_id, caller_scope_id, vertical)
1839
+ DO UPDATE SET target_scope_id = excluded.target_scope_id, invalidated = 0`, tenantId, callerScopeId, vertical, targetScopeId);
1840
+ }
1841
+ return { ok: true, changed: true, previous };
1842
+ }
1764
1843
  /**
1765
1844
  * Everything the router needs to place ONE peer call (#1706), in one round trip: the
1766
1845
  * CALLER's own scope record, and the target instance of `vertical` in that tenant with the
1767
1846
  * script and dispatch parameters that instance runs under.
1768
1847
  *
1769
- * One read rather than three, because this sits on a request path. The caller's record is
1848
+ * One directory RPC and two SQL reads, because this sits on a request path. The binding
1849
+ * joins the caller read, so even an unbound singleton adds no round trip. The caller's record is
1770
1850
  * what decides whether it may call at all — live, primary, and the vertical it claims — and
1771
1851
  * it is read HERE rather than inferred from routing, so this path does not inherit #1713.
1772
- * The target side applies the kernel's one rule (`resolveVerticalInstanceFrom`), the same
1852
+ * The target side applies the kernel's one rule (`resolvePeerInstanceFrom`), the same
1773
1853
  * rule the pure host and the local broker apply.
1774
1854
  */
1775
1855
  peerCallTarget(tenantId, callerScopeId, callerVertical, vertical) {
1776
1856
  const caller = this.sql
1777
- .exec(`SELECT s.tenant_id, s.vertical, s.status, s.kind, s.forked_from, t.status AS tenant_status
1778
- FROM scopes s LEFT JOIN tenants t ON t.tenant_id = s.tenant_id WHERE s.scope_id = ?`, callerScopeId)
1857
+ .exec(`SELECT s.tenant_id, s.vertical, s.status, s.kind, s.forked_from,
1858
+ t.status AS tenant_status, pb.target_scope_id AS bound_target_scope_id,
1859
+ pb.invalidated AS binding_invalidated
1860
+ FROM scopes s
1861
+ LEFT JOIN tenants t ON t.tenant_id = s.tenant_id
1862
+ LEFT JOIN peer_bindings pb ON pb.tenant_id = s.tenant_id
1863
+ AND pb.caller_scope_id = s.scope_id AND pb.vertical = ?
1864
+ WHERE s.scope_id = ?`, vertical, callerScopeId)
1779
1865
  .toArray()[0];
1780
1866
  const candidates = this.sql
1781
1867
  .exec(`SELECT s.scope_id, s.tenant_id, s.vertical, s.status, s.kind, s.forked_from,
@@ -1792,14 +1878,14 @@ export class ControlPlaneDO extends DurableObject {
1792
1878
  LEFT JOIN vertical_versions sv ON sv.id = vr.serving_version_id
1793
1879
  WHERE s.tenant_id = ? AND s.vertical = ?`, tenantId, vertical)
1794
1880
  .toArray();
1795
- const resolution = resolveVerticalInstanceFrom(candidates.map((r) => ({
1881
+ const resolution = resolvePeerInstanceFrom(candidates.map((r) => ({
1796
1882
  id: r.scope_id,
1797
1883
  tenantId: r.tenant_id,
1798
1884
  vertical: r.vertical,
1799
1885
  status: r.status,
1800
1886
  kind: r.kind ?? '',
1801
1887
  forkedFrom: r.forked_from ?? null,
1802
- })), tenantId, vertical);
1888
+ })), tenantId, vertical, caller?.bound_target_scope_id ?? null, caller?.binding_invalidated === 1);
1803
1889
  const target = resolution.outcome === 'resolved'
1804
1890
  ? (candidates.find((r) => r.scope_id === resolution.instance.scopeId) ?? null)
1805
1891
  : null;
@@ -2021,9 +2107,11 @@ export class ControlPlaneDO extends DurableObject {
2021
2107
  this.sql.exec(`UPDATE verticals SET serving_ref = ?, serving_version_id = ?,
2022
2108
  serving_do_classes = ?, serving_migration_tag = ? WHERE slug = ?`, s.ref, s.versionId, s.doClassesJson, s.migrationTag, slug);
2023
2109
  }
2024
- /** Point a scope's routing at the serving script its data now lives in (#286). */
2025
- setScopeServingRef(scopeId, servingRef) {
2026
- this.sql.exec('UPDATE scopes SET serving_ref = ? WHERE scope_id = ?', servingRef, scopeId);
2110
+ /** Point a scope's routing at the serving script its data now lives in (#286). False when a
2111
+ * conditional move lost its compare-and-set: the host throws, since a refusal thrown here
2112
+ * would cross the RPC boundary without its code. */
2113
+ setScopeServingRef(scopeId, servingRef, expectedErasureEpoch, confirmMove) {
2114
+ return this.moveScopeRow('serving_ref = ?', [servingRef], scopeId, { expectedErasureEpoch, confirmMove });
2027
2115
  }
2028
2116
  /** Move a fork's GC deadline forward, or pin it (`null`) — preview-and-snapshots.md §9. */
2029
2117
  setScopeExpiresAt(scopeId, expiresAt) {
@@ -2047,13 +2135,131 @@ export class ControlPlaneDO extends DurableObject {
2047
2135
  setAdmission(id, admission, note) {
2048
2136
  this.sql.exec('UPDATE vertical_versions SET admission = ?, admission_note = ? WHERE id = ?', admission, note, id);
2049
2137
  }
2050
- bindScopeVersion(scopeId, versionId, verticalSlug, expectedVersionId) {
2051
- const update = expectedVersionId === undefined
2052
- ? this.sql.exec('UPDATE scopes SET vertical_version_id = ?, vertical = ? WHERE scope_id = ?', versionId, verticalSlug, scopeId)
2053
- : this.sql.exec('UPDATE scopes SET vertical_version_id = ?, vertical = ? WHERE scope_id = ? AND vertical_version_id IS ?', versionId, verticalSlug, scopeId, expectedVersionId);
2054
- if (update.rowsWritten === 0) {
2055
- throw substratError('precondition_failed', 'scope binding changed; reload the scope and retry');
2138
+ /** False when a conditional bind lost its compare-and-set (the host throws, as above). */
2139
+ bindScopeVersion(scopeId, versionId, verticalSlug, expectedVersionId, expectedErasureEpoch, confirmMove) {
2140
+ return this.moveScopeRow('vertical_version_id = ?, vertical = ?', [versionId, verticalSlug], scopeId, { expectedVersionId, expectedErasureEpoch, confirmMove });
2141
+ }
2142
+ /**
2143
+ * Move a scope's binding or route (#1722), conditionally: on the binding the caller read, on
2144
+ * the erasure epoch its carry read (a NULL epoch, from before the column, is 0) with no reap
2145
+ * claimed, and on its copy move still owning a live lease. The move's confirmation commits in
2146
+ * the same transaction as the row. False when any condition failed and nothing was written.
2147
+ */
2148
+ moveScopeRow(set, setArgs, scopeId, cond) {
2149
+ const now = new Date().toISOString();
2150
+ return this.ctx.storage.transactionSync(() => {
2151
+ const moved = this.sql.exec(`UPDATE scopes SET ${set} WHERE scope_id = ?
2152
+ ${cond.expectedVersionId === undefined ? '' : 'AND vertical_version_id IS ?'}
2153
+ ${cond.expectedErasureEpoch === undefined ? '' : 'AND COALESCE(erasure_epoch, 0) = ? AND reap_claimed_at IS NULL'}
2154
+ ${cond.confirmMove === undefined ? '' : COPY_MOVE_LIVE_PREDICATE}`, ...setArgs, scopeId, ...(cond.expectedVersionId === undefined ? [] : [cond.expectedVersionId]), ...(cond.expectedErasureEpoch === undefined ? [] : [cond.expectedErasureEpoch]), ...(cond.confirmMove === undefined ? [] : copyMoveLiveParams(cond.confirmMove, now)));
2155
+ if (moved.rowsWritten === 0)
2156
+ return false;
2157
+ if (cond.confirmMove) {
2158
+ const { tenant_id } = this.sql.exec('SELECT tenant_id FROM scopes WHERE scope_id = ?', scopeId).one();
2159
+ this.sql.exec(COPY_MOVE_CONFIRM_SQL, ...copyMoveConfirmParams(cond.confirmMove, tenant_id, scopeId, now));
2160
+ }
2161
+ return true;
2162
+ });
2163
+ }
2164
+ scopeErasureEpoch(tenantId, scopeId) {
2165
+ // NULL on every row from before the column (#1722): the same 0 every CAS compares against.
2166
+ const row = this.sql.exec('SELECT COALESCE(erasure_epoch, 0) AS erasure_epoch FROM scopes WHERE tenant_id = ? AND scope_id = ?', tenantId, scopeId)
2167
+ .toArray()[0];
2168
+ if (!row)
2169
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2170
+ return row.erasure_epoch;
2171
+ }
2172
+ claimSubjectErasure(tenantId, scopeId, expectedVersionId, expectedServingRef, expectedEpoch, expectedCopyCount) {
2173
+ return this.sql.exec(`UPDATE scopes SET erasure_epoch = COALESCE(erasure_epoch, 0) + 1
2174
+ WHERE tenant_id = ? AND scope_id = ? AND vertical_version_id IS ?
2175
+ AND serving_ref IS ? AND COALESCE(erasure_epoch, 0) = ?
2176
+ AND NOT EXISTS (SELECT 1 FROM scope_script_copies AS copy
2177
+ WHERE copy.tenant_id = scopes.tenant_id AND copy.scope_id = scopes.scope_id
2178
+ AND copy.state = 'pending')
2179
+ AND (SELECT COUNT(*) FROM scope_script_copies AS copy
2180
+ WHERE copy.tenant_id = scopes.tenant_id AND copy.scope_id = scopes.scope_id) = ?`, tenantId, scopeId, expectedVersionId, expectedServingRef, expectedEpoch, expectedCopyCount).rowsWritten > 0;
2181
+ }
2182
+ recordScopeScriptCopy(tenantId, scopeId, scriptRef, moveId, role = null, loadStamp = null, leaseMs = SCOPE_COPY_LEASE_MS) {
2183
+ if (!scriptRef || !moveId)
2184
+ return 'invalid';
2185
+ const written = this.sql.exec(`INSERT INTO scope_script_copies (tenant_id, scope_id, script_ref, move_id, state, role, load_stamp, lease_until)
2186
+ SELECT tenant_id, scope_id, ?, ?, 'pending', ?, ?, ? FROM scopes
2187
+ WHERE tenant_id = ? AND scope_id = ? AND reap_claimed_at IS NULL
2188
+ ON CONFLICT (tenant_id, scope_id, script_ref, move_id) DO NOTHING`, scriptRef, moveId, role, loadStamp, new Date(Date.now() + leaseMs).toISOString(), tenantId, scopeId);
2189
+ if (written.rowsWritten > 0)
2190
+ return 'recorded';
2191
+ const scope = this.sql.exec('SELECT reap_claimed_at FROM scopes WHERE tenant_id = ? AND scope_id = ?', tenantId, scopeId)
2192
+ .toArray()[0];
2193
+ if (!scope)
2194
+ return 'missing';
2195
+ if (scope.reap_claimed_at !== null)
2196
+ return 'reaping';
2197
+ return 'recorded'; // idempotent retry of this move
2198
+ }
2199
+ claimExpiredScopeScriptCopies(input) {
2200
+ const limit = assertRowLimit('limit', input.limit);
2201
+ return this.ctx.storage.transactionSync(() => {
2202
+ const due = this.sql.exec(COPY_EXPIRED_SQL, input.now, limit).toArray();
2203
+ const claimed = [];
2204
+ for (const r of due) {
2205
+ if (this.sql.exec(COPY_CLAIM_SQL, input.owner, input.leaseUntil, r.tenant_id, r.scope_id, r.script_ref, r.move_id, input.now).rowsWritten === 0)
2206
+ continue;
2207
+ claimed.push(this.sql.exec(`SELECT ${SCOPE_SCRIPT_COPY_COLUMNS} FROM scope_script_copies
2208
+ WHERE tenant_id = ? AND scope_id = ? AND script_ref = ? AND move_id = ?`, r.tenant_id, r.scope_id, r.script_ref, r.move_id).one());
2209
+ }
2210
+ return claimed;
2211
+ });
2212
+ }
2213
+ beginScopeScriptReap(tenantId, scopeId) {
2214
+ const written = this.sql.exec(`UPDATE scopes SET reap_claimed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
2215
+ WHERE tenant_id = ? AND scope_id = ? AND reap_claimed_at IS NULL
2216
+ AND NOT EXISTS (SELECT 1 FROM scope_script_copies AS move
2217
+ WHERE move.tenant_id = scopes.tenant_id AND move.scope_id = scopes.scope_id
2218
+ AND move.state = 'pending')`, tenantId, scopeId);
2219
+ if (written.rowsWritten > 0)
2220
+ return 'claimed';
2221
+ const scope = this.sql.exec('SELECT reap_claimed_at FROM scopes WHERE tenant_id = ? AND scope_id = ?', tenantId, scopeId)
2222
+ .toArray()[0];
2223
+ if (!scope)
2224
+ return 'missing';
2225
+ return scope.reap_claimed_at !== null ? 'claimed' : 'pending'; // a reaper resumes its earlier claim
2226
+ }
2227
+ settleScopeScriptCopy(tenantId, scopeId, scriptRef, moveId, state, loadStamp, revision, claimedBy) {
2228
+ return this.sql.exec(`UPDATE scope_script_copies SET state = ?, load_stamp = ?, revision = ?,
2229
+ last_attempt_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
2230
+ WHERE tenant_id = ? AND scope_id = ? AND script_ref = ? AND move_id = ?
2231
+ AND (state <> 'done' OR ? = 'done')
2232
+ ${claimedBy === undefined ? '' : "AND state = 'pending' AND lease_owner = ?"}`, state, loadStamp, revision, tenantId, scopeId, scriptRef, moveId, state, ...(claimedBy === undefined ? [] : [claimedBy])).rowsWritten > 0;
2233
+ }
2234
+ touchScopeScriptCopy(tenantId, scopeId, scriptRef, moveId) {
2235
+ this.sql.exec(`UPDATE scope_script_copies SET last_attempt_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
2236
+ WHERE tenant_id = ? AND scope_id = ? AND script_ref = ? AND move_id = ? AND state = 'eligible'`, tenantId, scopeId, scriptRef, moveId);
2237
+ }
2238
+ listScopeScriptCopies(filter) {
2239
+ const where = [];
2240
+ const args = [];
2241
+ if (filter.tenantId) {
2242
+ where.push('tenant_id = ?');
2243
+ args.push(filter.tenantId);
2244
+ }
2245
+ if (filter.scopeId) {
2246
+ where.push('scope_id = ?');
2247
+ args.push(filter.scopeId);
2248
+ }
2249
+ if (filter.state) {
2250
+ where.push('state = ?');
2251
+ args.push(filter.state);
2252
+ }
2253
+ if (filter.after) {
2254
+ if (!filter.tenantId || !filter.scopeId)
2255
+ throw substratError('validation_failed', 'copy cursor requires tenant and scope');
2256
+ where.push('(script_ref > ? OR (script_ref = ? AND move_id > ?))');
2257
+ args.push(filter.after.scriptRef, filter.after.scriptRef, filter.after.moveId);
2056
2258
  }
2259
+ const limit = assertRowLimit('limit', filter.limit ?? 100);
2260
+ const order = filter.scopeId ? 'script_ref, move_id' : 'last_attempt_at, tenant_id, scope_id, script_ref';
2261
+ return this.sql.exec(`SELECT ${SCOPE_SCRIPT_COPY_COLUMNS} FROM scope_script_copies ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
2262
+ ORDER BY ${order} LIMIT ?`, ...args, limit).toArray();
2057
2263
  }
2058
2264
  /**
2059
2265
  * Record which version this scope's provision last ran against (#1172).
@@ -2077,6 +2283,7 @@ export class ControlPlaneDO extends DurableObject {
2077
2283
  */
2078
2284
  deleteScopeDirectory(scopeId) {
2079
2285
  this.ctx.storage.transactionSync(() => {
2286
+ this.sql.exec('UPDATE peer_bindings SET invalidated = 1 WHERE target_scope_id = ?', scopeId);
2080
2287
  this.sql.exec('DELETE FROM hostnames WHERE scope_id = ?', scopeId);
2081
2288
  forgetSwitchesOf(this.kernelSql, scopeId);
2082
2289
  this.forgetLifecycleDeliveries('scope_id = ?', scopeId);
@@ -2585,6 +2792,42 @@ export class ControlPlaneDO extends DurableObject {
2585
2792
  WHERE connection_id = ? AND revoked_at IS NULL`, at, id);
2586
2793
  return true;
2587
2794
  }
2795
+ // -- a vertical's mailed connect links (connections.md §3.5.4) ------------------
2796
+ // The kernel's statements (`connect-links.ts`), shared with the pure adapter. Each
2797
+ // runs inside this single-threaded object with no await between its read and its
2798
+ // write, which is what makes consume's single-use hold across racing callbacks here;
2799
+ // the UPDATE's own WHERE holds it again regardless. `now` is the coordinator's. A move
2800
+ // and the audit row it hands back commit in one unit: `audit` is the row the host
2801
+ // minted (actor, attribution), completed here from what the move changed.
2802
+ insertConnectLink(row, audit) {
2803
+ return this.auditedConnectLink(audit, (sql, write) => insertConnectLinkRow(sql, row, write));
2804
+ }
2805
+ readConnectLink(key) {
2806
+ return readConnectLinkRow(doRedactionSql(this.sql), key);
2807
+ }
2808
+ listConnectLinks(filter, now) {
2809
+ return listConnectLinkRows(doRedactionSql(this.sql), filter, now);
2810
+ }
2811
+ revokeConnectLink(key, audit) {
2812
+ return this.auditedConnectLink(audit, (sql, write) => revokeConnectLinkRow(sql, key, write));
2813
+ }
2814
+ consumeConnectLink(input, now, audit) {
2815
+ return this.auditedConnectLink(audit, (sql, write) => consumeConnectLinkRow(sql, input, now, write));
2816
+ }
2817
+ restoreConnectLink(key, now, audit) {
2818
+ return this.auditedConnectLink(audit, (sql, write) => restoreConnectLinkRow(sql, key, now, write));
2819
+ }
2820
+ auditedConnectLink(audit, run) {
2821
+ return this.ctx.storage.transactionSync(() => run(doRedactionSql(this.sql), (a) => this.recordAdmin({
2822
+ ...audit,
2823
+ action: a.action,
2824
+ tenantId: a.target.tenantId,
2825
+ scopeId: a.target.scopeId,
2826
+ vertical: a.target.vertical,
2827
+ before: a.before,
2828
+ after: a.after,
2829
+ })));
2830
+ }
2588
2831
  /**
2589
2832
  * Record a connection grant directory-side (#592) — written by `grantToConnection`
2590
2833
  * alongside the enforcement tuple, upserting in place on re-grant (the unique
@@ -2955,7 +3198,7 @@ export class ControlPlaneDO extends DurableObject {
2955
3198
  * #1713: the scopes whose lifecycle a deployment must hold, with their lifecycle as this
2956
3199
  * directory holds it. A scope with a vertical (only a deployment serves one), past provisioning
2957
3200
  * and not reaped (a reaped store must not be recreated by a delivery), under a tenant that is
2958
- * not reaped. `scopeId` narrows it to one scope and `tenantId` to one tenant's scopes, the
3201
+ * not reaped, of a vertical that does not declare its lifecycle held at the router. `scopeId` narrows it to one scope and `tenantId` to one tenant's scopes, the
2959
3202
  * fan-out of a tenant transition. With `drift`, only the scopes the heal sweep must deliver to:
2960
3203
  * a receipt that differs from the directory, and every scope held now. Re-delivering a held
2961
3204
  * scope every pass is what puts a hold back on a store a carry or a restore landed without it,
@@ -2966,6 +3209,10 @@ export class ControlPlaneDO extends DurableObject {
2966
3209
  's.vertical IS NOT NULL',
2967
3210
  "s.status NOT IN ('provisioning', 'reaped')",
2968
3211
  "t.status <> 'reaped'",
3212
+ // A vertical that declares its lifecycle held at the router (`lifecycleHold`) has no route
3213
+ // to deliver to and nothing a delivery would hold: none of its scopes is a target, for a
3214
+ // transition, the heal's drift, or the #2016 tenant-record ask alike.
3215
+ `COALESCE(CASE WHEN json_valid(v.install_spec) THEN json_extract(v.install_spec, '$.lifecycle') END, '') <> 'router'`,
2969
3216
  ];
2970
3217
  const params = [];
2971
3218
  if (filter.tenantId) {
@@ -3004,6 +3251,7 @@ export class ControlPlaneDO extends DurableObject {
3004
3251
  ${epoch} AS epoch, COALESCE(rs.revision, 0) AS scope_rev, COALESCE(rt.revision, 0) AS tenant_rev
3005
3252
  FROM scopes s
3006
3253
  JOIN tenants t ON t.tenant_id = s.tenant_id
3254
+ LEFT JOIN verticals v ON v.slug = s.vertical
3007
3255
  LEFT JOIN scope_lifecycle_receipts r ON r.scope_id = s.scope_id
3008
3256
  LEFT JOIN scope_tenant_asks a ON a.scope_id = s.scope_id
3009
3257
  LEFT JOIN lifecycle_revisions rs ON rs.subject = 'scope:' || s.scope_id
@@ -3055,50 +3303,99 @@ export class ControlPlaneDO extends DurableObject {
3055
3303
  recordOpsFailure(row) {
3056
3304
  // #1748: the evidence, its issue and its finding in ONE unit — a detector that throws takes
3057
3305
  // the evidence with it, so the caller's retry observes it rather than losing the observation.
3058
- this.ctx.storage.transactionSync(() => {
3059
- this.sql.exec(`INSERT INTO _substrat_ops_failures
3060
- (id, actor, operation, stage, tenant_id, scope_id, vertical, version, status, message, reference, origin, code, fingerprint, at)
3061
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, row.id, row.actor, row.operation, row.stage, row.tenant_id, row.scope_id, row.vertical, row.version, row.status, row.message, row.reference, row.origin, row.code, row.fingerprint, row.at);
3062
- // Prune-on-write (#559): the retention lives HERE, not in a cron — every insert
3063
- // pays for its own housekeeping, so the table stays bounded even on a deployment
3064
- // whose scheduled pass is broken (the exact circumstance this table records).
3065
- const horizon = new Date(Date.now() - OPS_FAILURE_RETENTION_DAYS * 86_400_000).toISOString();
3066
- this.sql.exec('DELETE FROM _substrat_ops_failures WHERE at < ?', horizon);
3067
- // The issues materialization (#1233): the group's counters live on their own
3068
- // row, bumped in the same call, because the evidence self-prunes above and a
3069
- // count must survive its own exemplars. A fresh arrival regresses a resolved
3070
- // issue; an ignored one stays ignored — that is what ignoring means.
3071
- if (row.fingerprint !== null) {
3072
- this.sql.exec(`INSERT INTO _substrat_issues
3073
- (fingerprint, operation, stage, origin, code, status, seen_count, first_seen, last_seen, last_message, last_tenant_id, last_owner_kind, last_vertical, last_version, resolved_version, resolved_at)
3074
- VALUES (?, ?, ?, ?, ?, 'new', 1, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
3075
- ON CONFLICT (fingerprint) DO UPDATE SET
3076
- seen_count = seen_count + 1,
3077
- last_seen = excluded.last_seen,
3078
- last_message = excluded.last_message,
3079
- last_tenant_id = excluded.last_tenant_id,
3080
- last_owner_kind = excluded.last_owner_kind,
3081
- last_vertical = COALESCE(excluded.last_vertical, last_vertical),
3082
- last_version = COALESCE(excluded.last_version, last_version),
3083
- origin = COALESCE(excluded.origin, origin),
3084
- status = CASE WHEN status = 'resolved' THEN 'regressed' ELSE status END`, row.fingerprint, row.operation, row.stage, row.origin, row.code, row.at, row.at, row.message, row.tenant_id, issueExemplarOwner(row.tenant_id), row.vertical, row.version);
3085
- const issueHorizon = new Date(Date.now() - ISSUE_RETENTION_DAYS * 86_400_000).toISOString();
3086
- this.sql.exec('DELETE FROM _substrat_issues WHERE last_seen < ?', issueHorizon);
3087
- // #1748: the tenant's Recurring finding, over the tenant's own occurrences only.
3088
- const finding = findingOfOpsFailure({
3089
- tenantId: row.tenant_id,
3090
- scopeId: row.scope_id,
3091
- operation: row.operation,
3092
- stage: row.stage,
3093
- code: row.code,
3094
- vertical: row.vertical,
3095
- version: row.version,
3096
- });
3097
- if (finding)
3098
- observeFinding(doRedactionSql(this.sql), finding, row.at);
3306
+ this.ctx.storage.transactionSync(() => this.insertOpsFailure(row));
3307
+ }
3308
+ /**
3309
+ * #2064: settle an audited-change intent with no outcome — read it, and only if no outcome row
3310
+ * exists for its operation, write the `unknown` row and its ops-failure row. One unit, so two
3311
+ * racing passes settle once and the digest row cannot be lost. See the kernel's `audit-outcome.ts`.
3312
+ */
3313
+ settleUnrecordedOutcome(input) {
3314
+ return this.ctx.storage.transactionSync(() => {
3315
+ const row = this.sql.exec(SETTLE_INTENT_SQL, input.intentId).toArray()[0];
3316
+ const outcome = unknownOutcomeOf(row, input.intentId, input.error);
3317
+ if (this.sql.exec(SETTLE_OUTCOME_SQL, ...settleOutcomeParamsOf(outcome.operation)).toArray().length > 0) {
3318
+ return false;
3099
3319
  }
3320
+ const at = new Date().toISOString();
3321
+ this.recordAdmin({
3322
+ id: ulid(),
3323
+ actor: input.actor,
3324
+ action: outcome.action,
3325
+ ...outcome.target,
3326
+ before: null,
3327
+ after: outcome.after,
3328
+ at,
3329
+ });
3330
+ const failure = { ...outcome.failure, actor: input.actor };
3331
+ this.insertOpsFailure({
3332
+ id: ulid(),
3333
+ actor: input.actor,
3334
+ operation: failure.operation,
3335
+ stage: failure.stage ?? null,
3336
+ tenant_id: failure.tenantId ?? null,
3337
+ scope_id: failure.scopeId ?? null,
3338
+ vertical: failure.vertical ?? null,
3339
+ version: null,
3340
+ status: null,
3341
+ message: failure.message.slice(0, 2000),
3342
+ reference: failure.reference ?? null,
3343
+ origin: null,
3344
+ code: null,
3345
+ fingerprint: opsFailureFingerprint(failure),
3346
+ at,
3347
+ });
3348
+ return true;
3100
3349
  });
3101
3350
  }
3351
+ /** #2064: an audited operation's rows, by the operation-id index, in bounded batches. */
3352
+ auditedOperations(refs) {
3353
+ return readAuditedOperations((sql, params) => this.sql.exec(sql, ...params).toArray(), refs);
3354
+ }
3355
+ /** The body of `recordOpsFailure`, for a caller already inside a transaction. */
3356
+ insertOpsFailure(row) {
3357
+ this.sql.exec(`INSERT INTO _substrat_ops_failures
3358
+ (id, actor, operation, stage, tenant_id, scope_id, vertical, version, status, message, reference, origin, code, fingerprint, at)
3359
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, row.id, row.actor, row.operation, row.stage, row.tenant_id, row.scope_id, row.vertical, row.version, row.status, row.message, row.reference, row.origin, row.code, row.fingerprint, row.at);
3360
+ // Prune-on-write (#559): the retention lives HERE, not in a cron — every insert
3361
+ // pays for its own housekeeping, so the table stays bounded even on a deployment
3362
+ // whose scheduled pass is broken (the exact circumstance this table records).
3363
+ const horizon = new Date(Date.now() - OPS_FAILURE_RETENTION_DAYS * 86_400_000).toISOString();
3364
+ this.sql.exec('DELETE FROM _substrat_ops_failures WHERE at < ?', horizon);
3365
+ // The issues materialization (#1233): the group's counters live on their own
3366
+ // row, bumped in the same call, because the evidence self-prunes above and a
3367
+ // count must survive its own exemplars. A fresh arrival regresses a resolved
3368
+ // issue; an ignored one stays ignored — that is what ignoring means.
3369
+ if (row.fingerprint !== null) {
3370
+ this.sql.exec(`INSERT INTO _substrat_issues
3371
+ (fingerprint, operation, stage, origin, code, status, seen_count, first_seen, last_seen, last_message, last_tenant_id, last_owner_kind, last_vertical, last_version, resolved_version, resolved_at)
3372
+ VALUES (?, ?, ?, ?, ?, 'new', 1, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
3373
+ ON CONFLICT (fingerprint) DO UPDATE SET
3374
+ seen_count = seen_count + 1,
3375
+ last_seen = excluded.last_seen,
3376
+ last_message = excluded.last_message,
3377
+ last_tenant_id = excluded.last_tenant_id,
3378
+ last_owner_kind = excluded.last_owner_kind,
3379
+ last_vertical = COALESCE(excluded.last_vertical, last_vertical),
3380
+ last_version = COALESCE(excluded.last_version, last_version),
3381
+ origin = COALESCE(excluded.origin, origin),
3382
+ status = CASE WHEN status = 'resolved' THEN 'regressed' ELSE status END`, row.fingerprint, row.operation, row.stage, row.origin, row.code, row.at, row.at, row.message, row.tenant_id, issueExemplarOwner(row.tenant_id), row.vertical, row.version);
3383
+ const issueHorizon = new Date(Date.now() - ISSUE_RETENTION_DAYS * 86_400_000).toISOString();
3384
+ this.sql.exec('DELETE FROM _substrat_issues WHERE last_seen < ?', issueHorizon);
3385
+ // #1748: the tenant's Recurring finding, over the tenant's own occurrences only.
3386
+ const finding = findingOfOpsFailure({
3387
+ tenantId: row.tenant_id,
3388
+ scopeId: row.scope_id,
3389
+ operation: row.operation,
3390
+ stage: row.stage,
3391
+ code: row.code,
3392
+ vertical: row.vertical,
3393
+ version: row.version,
3394
+ });
3395
+ if (finding)
3396
+ observeFinding(doRedactionSql(this.sql), finding, row.at);
3397
+ }
3398
+ }
3102
3399
  /**
3103
3400
  * #1632: subject erasure's directory half — the failure text this directory keeps about a
3104
3401
  * scope's subject. The kernel's walk, so the SQLite host runs the identical statements.