@substrat-run/adapter-cloudflare 0.137.0 → 0.139.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,8 +1,9 @@
1
1
  import { DurableObject } from 'cloudflare:workers';
2
- import { IMPERSONATION_COLUMNS, IMPERSONATION_DDL, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, assertRowLimit, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, SYSTEM_SWITCHES_BACKFILL_SQL, SYSTEM_SWITCHES_TABLE, SYSTEM_SWITCHES_DDL, forgetSystemSwitchesOf, listSystemSwitchRecords, recordSystemSwitchedOff, recordSystemSwitchedOn, restoreSystemSwitchRecord, scopesSwitchedOffFor, tenantHoldsSystemGrant, switchedOffModulesOf, systemSwitchRecordsOf, systemSwitchesTableExists, 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, 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';
3
+ import { replyOf } from './do-reply.js';
3
4
  import { splitSqlStatements, switchSqlOver } from './scope-do.js';
4
5
  import { doBuiltColumnsOf, doRedactionSql } from './sql.js';
5
- import { assertReplayableDump, opsFailureFingerprint, ROUTE_SCOPE_HOSTNAMES_MAX, substratError } from '@substrat-run/contracts';
6
+ import { assertReplayableDump, opsFailureFingerprint, ROUTE_SCOPE_HOSTNAMES_MAX, SCOPE_GATE_REASONS, substratError } from '@substrat-run/contracts';
6
7
  /**
7
8
  * The router's per-request directory read (`readRoute`), lifted out so its plan can be asked for
8
9
  * (#2005): the scope's hostname set is a correlated subquery on the hot path every request takes,
@@ -456,6 +457,9 @@ const DIRECTORY_DDL = `
456
457
  );
457
458
  ${IMPERSONATION_DDL}
458
459
  ${SYSTEM_SWITCHES_DDL}
460
+ ${MEMBERSHIP_FENCES_DDL}
461
+ ${PEER_SWITCHES_DDL}
462
+ ${SWITCH_OWED_DDL}
459
463
  CREATE TABLE IF NOT EXISTS _substrat_admin_log (
460
464
  id TEXT PRIMARY KEY,
461
465
  actor TEXT NOT NULL,
@@ -537,6 +541,8 @@ const DIRECTORY_DDL = `
537
541
  resolved_at TEXT
538
542
  );
539
543
  CREATE INDEX IF NOT EXISTS _substrat_issues_seen ON _substrat_issues (last_seen);
544
+ -- #1748: tenant findings and their suppress rules - kernel-owned DDL (findings.ts).
545
+ ${FINDINGS_DDL}
540
546
  -- The durable sweep record (#1232): one row per unit outcome per pass - a
541
547
  -- connection swept/skipped/failed, a schedule fired/skipped/failed. What makes
542
548
  -- "when was this last swept" answerable after the log line rolls off. Pruned on
@@ -709,6 +715,21 @@ export const BACKFILL_FAILURES_KEY = 'versionMigrationsBackfillFailures';
709
715
  export function backfillBackoffMs(failures) {
710
716
  return Math.min(BACKFILL_PAUSE_MS * 2 ** failures, BACKFILL_BACKOFF_MAX_MS);
711
717
  }
718
+ /** The directory writes `ControlPlaneDO.reply` answers for (#113): each refuses with a typed throw. */
719
+ export const REPLIED_METHODS = [
720
+ 'createTenant',
721
+ 'setTenantStatus',
722
+ 'reapTenant',
723
+ 'setTenantName',
724
+ 'putTenantStore',
725
+ 'putBlobStore',
726
+ 'provisionScope',
727
+ 'createOrg',
728
+ 'registerIdentityPool',
729
+ 'linkIdentity',
730
+ // #1748: refuses an expiry outside its bounds with validation_failed.
731
+ 'createFindingRule',
732
+ ];
712
733
  export class ControlPlaneDO extends DurableObject {
713
734
  sql;
714
735
  /** The directory's store as the kernel's SQL handle — the switch record's helpers (#1674). */
@@ -792,20 +813,30 @@ export class ControlPlaneDO extends DurableObject {
792
813
  * construction. A directory restore runs `buildDirectorySchema` inside its own transaction
793
814
  * instead (`importDump`).
794
815
  *
795
- * The schedule switch's record (#1674) is backfilled from the admin log on a run that
796
- * creates its table, and its table is created in the SAME transaction as the backfill
797
- * (Copilot review): the gate is "the table does not exist yet", so a table committed ahead
798
- * of a backfill that then failed would read as already migrated on every later run. On
816
+ * Each kill switch's record (#1674, and #2029's for a peer) is backfilled from the admin log
817
+ * on a run that creates its table, and its table is created in the SAME transaction as the
818
+ * backfill (Copilot review): the gate is "the table does not exist yet", so a table committed
819
+ * ahead of a backfill that then failed would read as already migrated on every later run. On
799
820
  * such a run its statements are held back from the loop below and run with the backfill.
800
821
  */
801
822
  applyDirectorySchema() {
802
- const switchRecordIsNew = !systemSwitchesTableExists(this.kernelSql);
803
- this.buildDirectorySchema({ holdSwitchRecord: switchRecordIsNew });
804
- if (switchRecordIsNew) {
823
+ const newRecords = SWITCH_KINDS.filter((kind) => !switchesTableExists(this.kernelSql, kind));
824
+ const fencesAreNew = !membershipFencesTableExists(this.kernelSql);
825
+ this.buildDirectorySchema({ holdSwitchRecords: newRecords, holdFences: fencesAreNew });
826
+ for (const kind of newRecords) {
827
+ this.ctx.storage.transactionSync(() => {
828
+ for (const stmt of splitSqlStatements(switchesDdlOf(kind)))
829
+ this.sql.exec(stmt);
830
+ this.sql.exec(switchesBackfillSqlOf(kind));
831
+ });
832
+ }
833
+ // #1184: the removal fence and its backfill from the admin log, together, the same way.
834
+ if (fencesAreNew) {
805
835
  this.ctx.storage.transactionSync(() => {
806
- for (const stmt of splitSqlStatements(SYSTEM_SWITCHES_DDL))
836
+ for (const stmt of splitSqlStatements(MEMBERSHIP_FENCES_DDL))
807
837
  this.sql.exec(stmt);
808
- this.sql.exec(SYSTEM_SWITCHES_BACKFILL_SQL);
838
+ // Stamped now, inside this unit (`membershipFencesBackfillSql` says why).
839
+ this.sql.exec(membershipFencesBackfillSql(new Date().toISOString()));
809
840
  });
810
841
  }
811
842
  }
@@ -813,12 +844,15 @@ export class ControlPlaneDO extends DurableObject {
813
844
  * The directory's tables as this code builds them, carrying whatever the directory already
814
845
  * holds forward to that shape: the construction's pass, and a restore's (#1898, #1912), which
815
846
  * runs it inside its transaction onto an emptied directory, before loading any row.
816
- * `holdSwitchRecord` leaves the #1674 record's statements to the caller, which creates the
817
- * table with its backfill.
847
+ * `holdSwitchRecords` leaves those kinds' record statements (#1674, #2029) to the caller, which
848
+ * creates each table with its backfill; `holdFences` does the same for #1184's removal fence.
818
849
  */
819
- buildDirectorySchema({ holdSwitchRecord }) {
850
+ buildDirectorySchema({ holdSwitchRecords, holdFences = false }) {
851
+ const held = holdSwitchRecords.map(switchesTableOf);
820
852
  for (const stmt of DIRECTORY_DDL_PLAN.loop) {
821
- if (holdSwitchRecord && stmt.includes(SYSTEM_SWITCHES_TABLE))
853
+ if (held.some((table) => stmt.includes(table)))
854
+ continue;
855
+ if (holdFences && stmt.includes(MEMBERSHIP_FENCES_TABLE))
822
856
  continue;
823
857
  this.sql.exec(stmt);
824
858
  }
@@ -1135,12 +1169,13 @@ export class ControlPlaneDO extends DurableObject {
1135
1169
  // Every table from this code's DDL, onto an empty directory, so none of the pass's
1136
1170
  // rebuilds fires (each would reach `transactionSync`, which this async transaction cannot
1137
1171
  // hold): every table is created on the current shape.
1138
- this.buildDirectorySchema({ holdSwitchRecord: false });
1172
+ this.buildDirectorySchema({ holdSwitchRecords: [] });
1139
1173
  // Refuses a table this code does not build, adds unknown columns bare, loads the rows by
1140
1174
  // name, then the legacy scope and #1674 backfills, all inside this transaction.
1141
1175
  loadDirectoryDump(tables, {
1142
1176
  columnsOf: (name) => doBuiltColumnsOf(this.sql, name),
1143
1177
  exec: (sql) => this.sql.exec(sql),
1178
+ now: () => new Date().toISOString(),
1144
1179
  maxColumns: DO_SQL_LIMITS.columns,
1145
1180
  insert: (sql, rows) => {
1146
1181
  for (const row of rows)
@@ -1199,7 +1234,7 @@ export class ControlPlaneDO extends DurableObject {
1199
1234
  .exec('SELECT tenant_id FROM tenants WHERE slug = ?', slug)
1200
1235
  .toArray()[0];
1201
1236
  if (slugOwner) {
1202
- throw new Error(`tenant slug '${slug}' already taken by ${slugOwner.tenant_id} (slugs are unique)`);
1237
+ throw substratError('conflict', `tenant slug '${slug}' already taken by ${slugOwner.tenant_id} (slugs are unique)`);
1203
1238
  }
1204
1239
  this.sql.exec(`INSERT INTO tenants (tenant_id, slug, name, status, created_at, provisioned_by_tenant)
1205
1240
  VALUES (?, ?, ?, 'active', ?, ?)`, id, slug, name, createdAt, provisionedByTenant);
@@ -1209,12 +1244,12 @@ export class ControlPlaneDO extends DurableObject {
1209
1244
  setTenantStatus(tenantId, status) {
1210
1245
  const before = this.readTenant(tenantId);
1211
1246
  if (!before)
1212
- throw new Error(`unknown tenant: ${tenantId}`);
1247
+ throw substratError('not_found', `unknown tenant: ${tenantId}`);
1213
1248
  // `reaped` is terminal and destroys data — unreachable here, only ever set by
1214
1249
  // reapTenant, so a plain status flip cannot forge a tombstone over live data
1215
1250
  // (§4.8, the tenant analogue of reapScope's archived-only gate).
1216
1251
  if (status === 'reaped') {
1217
- throw new Error(`tenant ${tenantId} cannot be set to 'reaped' via setTenantStatus — reap goes through reapTenant (control-plane.md §4.8)`);
1252
+ throw substratError('validation_failed', `tenant ${tenantId} cannot be set to 'reaped' via setTenantStatus — reap goes through reapTenant (control-plane.md §4.8)`);
1218
1253
  }
1219
1254
  // Stamp/clear deleting_at so the grace-window sweep can age tenants (§4.8):
1220
1255
  // entering `deleting` stamps, leaving it (an un-delete) clears — the same shape
@@ -1241,9 +1276,9 @@ export class ControlPlaneDO extends DurableObject {
1241
1276
  reapTenant(tenantId) {
1242
1277
  const before = this.readTenant(tenantId);
1243
1278
  if (!before)
1244
- throw new Error(`unknown tenant: ${tenantId}`);
1279
+ throw substratError('not_found', `unknown tenant: ${tenantId}`);
1245
1280
  if (before.status !== 'deleting') {
1246
- throw new Error(`tenant ${tenantId} is ${before.status}, not deleting — only a deleting tenant may be reaped`);
1281
+ throw substratError('conflict', `tenant ${tenantId} is ${before.status}, not deleting — only a deleting tenant may be reaped`);
1247
1282
  }
1248
1283
  // KEPT as the tombstone: the `tenants` row itself and `_substrat_admin_log` (the
1249
1284
  // compliance witness — never swept). Scope rows were already reaped individually.
@@ -1255,6 +1290,11 @@ export class ControlPlaneDO extends DurableObject {
1255
1290
  '_substrat_entitlements', // per-tenant SKU flags
1256
1291
  'orgs', // K-22 org records
1257
1292
  '_substrat_system_switches', // #1674: the schedule switch's record, per scope
1293
+ '_substrat_membership_fences', // #1184: the latest removal, per principal
1294
+ '_substrat_peer_switches', // #2029: the peer switch's record, per scope
1295
+ '_substrat_switch_owed', // #2045: subjects owed a re-assert, per scope
1296
+ '_substrat_findings', // #1748: the tenant's findings
1297
+ '_substrat_finding_rules', // #1748: the tenant's suppress rules
1258
1298
  ]) {
1259
1299
  this.sql.exec(`DELETE FROM ${table} WHERE tenant_id = ?`, tenantId);
1260
1300
  }
@@ -1270,7 +1310,7 @@ export class ControlPlaneDO extends DurableObject {
1270
1310
  setTenantName(tenantId, name) {
1271
1311
  const before = this.readTenant(tenantId);
1272
1312
  if (!before)
1273
- throw new Error(`unknown tenant: ${tenantId}`);
1313
+ throw substratError('not_found', `unknown tenant: ${tenantId}`);
1274
1314
  this.sql.exec('UPDATE tenants SET name = ? WHERE tenant_id = ?', name, tenantId);
1275
1315
  return before.name;
1276
1316
  }
@@ -1304,10 +1344,10 @@ export class ControlPlaneDO extends DurableObject {
1304
1344
  .exec('SELECT status FROM tenants WHERE tenant_id = ?', row.tenantId)
1305
1345
  .toArray()[0];
1306
1346
  if (!tenantRow) {
1307
- throw new Error(`cannot provision tenant store under unknown tenant: ${row.tenantId}`);
1347
+ throw substratError('not_found', `cannot provision tenant store under unknown tenant: ${row.tenantId}`);
1308
1348
  }
1309
1349
  if (tenantRow.status !== 'active') {
1310
- throw new Error(`cannot provision tenant store under non-active tenant (status: ${tenantRow.status}): ${row.tenantId}`);
1350
+ throw substratError('conflict', `cannot provision tenant store under non-active tenant (status: ${tenantRow.status}): ${row.tenantId}`);
1311
1351
  }
1312
1352
  this.sql.exec(`INSERT OR IGNORE INTO tenant_stores (tenant_id, vertical, binding, kind, ref, created_at)
1313
1353
  VALUES (?, ?, ?, ?, ?, ?)`, row.tenantId, row.vertical, row.binding, row.kind, row.ref, row.createdAt);
@@ -1348,10 +1388,10 @@ export class ControlPlaneDO extends DurableObject {
1348
1388
  .exec('SELECT status FROM tenants WHERE tenant_id = ?', row.tenantId)
1349
1389
  .toArray()[0];
1350
1390
  if (!tenantRow) {
1351
- throw new Error(`cannot provision blob store under unknown tenant: ${row.tenantId}`);
1391
+ throw substratError('not_found', `cannot provision blob store under unknown tenant: ${row.tenantId}`);
1352
1392
  }
1353
1393
  if (tenantRow.status !== 'active') {
1354
- throw new Error(`cannot provision blob store under non-active tenant (status: ${tenantRow.status}): ${row.tenantId}`);
1394
+ throw substratError('conflict', `cannot provision blob store under non-active tenant (status: ${tenantRow.status}): ${row.tenantId}`);
1355
1395
  }
1356
1396
  this.sql.exec(`INSERT OR IGNORE INTO blob_stores (tenant_id, vertical, binding, kind, ref, created_at)
1357
1397
  VALUES (?, ?, ?, ?, ?, ?)`, row.tenantId, row.vertical, row.binding, row.kind, row.ref, row.createdAt);
@@ -1388,10 +1428,10 @@ export class ControlPlaneDO extends DurableObject {
1388
1428
  .exec('SELECT status FROM tenants WHERE tenant_id = ?', tenantId)
1389
1429
  .toArray()[0];
1390
1430
  if (!tenantRow) {
1391
- throw new Error(`cannot provision scope under unknown tenant: ${tenantId}`);
1431
+ throw substratError('conflict', `cannot provision scope under unknown tenant: ${tenantId}`);
1392
1432
  }
1393
1433
  if (tenantRow.status !== 'active') {
1394
- throw new Error(`cannot provision scope under non-active tenant (status: ${tenantRow.status}): ${tenantId}`);
1434
+ throw substratError('conflict', `cannot provision scope under non-active tenant (status: ${tenantRow.status}): ${tenantId}`);
1395
1435
  }
1396
1436
  // Idempotency is on the scope_id (§3.3: idempotent and journaled — safe to
1397
1437
  // re-run), so an existing scope short-circuits before the slug check:
@@ -1404,7 +1444,7 @@ export class ControlPlaneDO extends DurableObject {
1404
1444
  .exec("SELECT scope_id FROM scopes WHERE tenant_id = ? AND slug = ? AND status NOT IN ('archived', 'reaped')", tenantId, record.slug)
1405
1445
  .toArray()[0];
1406
1446
  if (slugOwner) {
1407
- throw new Error(`scope slug '${record.slug}' already taken under tenant ${tenantId} ` +
1447
+ throw substratError('conflict', `scope slug '${record.slug}' already taken under tenant ${tenantId} ` +
1408
1448
  `by ${slugOwner.scope_id} (slugs are unique within a tenant)`);
1409
1449
  }
1410
1450
  // A scope born while its vertical serves in place (#286) is born ON the serving
@@ -1486,6 +1526,21 @@ export class ControlPlaneDO extends DurableObject {
1486
1526
  return undefined;
1487
1527
  return row;
1488
1528
  }
1529
+ /**
1530
+ * A directory write, its refusal answered as DATA (#113): the typed throws in these methods
1531
+ * would reach the coordinator flattened, so it calls them through here and rethrows the
1532
+ * refusal with its code (`unwrapReply`). Called directly, they still throw — a coordinator
1533
+ * from before this change does — and that caller reads the code folded into the message.
1534
+ */
1535
+ async reply(method, args) {
1536
+ return replyOf(() => {
1537
+ // An RPC argument, so held to the list rather than trusted to be one of its names.
1538
+ if (!REPLIED_METHODS.includes(method))
1539
+ throw new Error(`not a replied directory method: ${String(method)}`);
1540
+ const run = this[method];
1541
+ return run.apply(this, args);
1542
+ });
1543
+ }
1489
1544
  /**
1490
1545
  * The getScope gate (control-plane.md §4.1/§4.2): validate the scope belongs
1491
1546
  * to the tenant and both records are active, or throw the fail-closed reason.
@@ -1502,11 +1557,10 @@ export class ControlPlaneDO extends DurableObject {
1502
1557
  * The same gate, answering its refusal as DATA (#1718). An error thrown in a Durable
1503
1558
  * Object arrives at the coordinator flattened, its code gone, so a typed throw here
1504
1559
  * would still reach the caller untyped (#1714 measured it). A record crosses intact, so
1505
- * the coordinator throws the refusal itself, typed. `code` is set only where a code is
1506
- * known; the lifecycle refusals keep their bare messages. Null means access is allowed.
1560
+ * the coordinator throws the refusal itself, typed (#113: every refusal but a failed
1561
+ * migration carries its code). Null means access is allowed.
1507
1562
  */
1508
1563
  scopeAccessRefusal(tenantId, scopeId) {
1509
- const refuse = (message) => ({ code: null, message });
1510
1564
  const row = this.sql
1511
1565
  .exec('SELECT tenant_id, status FROM scopes WHERE scope_id = ?', scopeId)
1512
1566
  .toArray()[0];
@@ -1517,10 +1571,14 @@ export class ControlPlaneDO extends DurableObject {
1517
1571
  .exec('SELECT status FROM tenants WHERE tenant_id = ?', tenantId)
1518
1572
  .toArray()[0];
1519
1573
  if (!tenantRow) {
1520
- return refuse(`scope has no tenant record: (${tenantId}, ${scopeId})`);
1574
+ return { code: 'not_found', message: `scope has no tenant record: (${tenantId}, ${scopeId})`, reason: SCOPE_GATE_REASONS.unrecorded };
1521
1575
  }
1522
1576
  if (tenantRow.status !== 'active') {
1523
- return refuse(`tenant not active (status: ${tenantRow.status}): ${tenantId}`);
1577
+ return {
1578
+ code: 'conflict',
1579
+ message: `tenant not active (status: ${tenantRow.status}): ${tenantId}`,
1580
+ reason: SCOPE_GATE_REASONS.notActive,
1581
+ };
1524
1582
  }
1525
1583
  if (row.status !== 'active') {
1526
1584
  // A scope stuck in provisioning because its migrations failed must say so.
@@ -1531,10 +1589,17 @@ export class ControlPlaneDO extends DurableObject {
1531
1589
  .exec('SELECT migration_failed_version, migration_error FROM scopes WHERE scope_id = ?', scopeId)
1532
1590
  .toArray()[0];
1533
1591
  if (failure?.migration_failed_version) {
1534
- return refuse(`migration failed for ${failure.migration_failed_version} — scope fails closed: ` +
1535
- `${failure.migration_error ?? 'unknown error'}`);
1592
+ return {
1593
+ code: null,
1594
+ message: `migration failed for ${failure.migration_failed_version} — scope fails closed: ` +
1595
+ `${failure.migration_error ?? 'unknown error'}`,
1596
+ };
1536
1597
  }
1537
- return refuse(`scope not active (status: ${row.status}): ${scopeId}`);
1598
+ return {
1599
+ code: 'conflict',
1600
+ message: `scope not active (status: ${row.status}): ${scopeId}`,
1601
+ reason: SCOPE_GATE_REASONS.notActive,
1602
+ };
1538
1603
  }
1539
1604
  return null;
1540
1605
  }
@@ -1569,7 +1634,7 @@ export class ControlPlaneDO extends DurableObject {
1569
1634
  if (!from.includes(row.status)) {
1570
1635
  return {
1571
1636
  ok: false,
1572
- code: null,
1637
+ code: 'conflict',
1573
1638
  message: `illegal scope transition for ${action}: ${row.status} → ${to} ` +
1574
1639
  `(allowed from: ${from.join('|')})`,
1575
1640
  };
@@ -1590,7 +1655,7 @@ export class ControlPlaneDO extends DurableObject {
1590
1655
  this.sql.exec('UPDATE scopes SET status = ? WHERE scope_id = ?', to, scopeId);
1591
1656
  }
1592
1657
  if (to === 'reaped') {
1593
- forgetSystemSwitchesOf(this.kernelSql, scopeId);
1658
+ forgetSwitchesOf(this.kernelSql, scopeId);
1594
1659
  this.forgetLifecycleDeliveries('scope_id = ?', scopeId);
1595
1660
  }
1596
1661
  this.bumpLifecycleRevision(`scope:${scopeId}`); // #1713, with the status it counts
@@ -1998,6 +2063,11 @@ export class ControlPlaneDO extends DurableObject {
1998
2063
  * for a repair that failed, which is the one outcome that must keep being retried.
1999
2064
  */
2000
2065
  markScopeProvisioned(scopeId, versionId) {
2066
+ // #2045: a scope owed a switch re-assert keeps no receipt, so the sweep reconciles it again
2067
+ // until its re-assert has settled every mark — including a mark written while this
2068
+ // reconcile ran, after its re-assert had read none.
2069
+ if (versionId !== null && scopeOwesSwitch(this.kernelSql, scopeId))
2070
+ versionId = null;
2001
2071
  this.sql.exec('UPDATE scopes SET provisioned_version_id = ? WHERE scope_id = ?', versionId, scopeId);
2002
2072
  }
2003
2073
  /**
@@ -2008,7 +2078,7 @@ export class ControlPlaneDO extends DurableObject {
2008
2078
  deleteScopeDirectory(scopeId) {
2009
2079
  this.ctx.storage.transactionSync(() => {
2010
2080
  this.sql.exec('DELETE FROM hostnames WHERE scope_id = ?', scopeId);
2011
- forgetSystemSwitchesOf(this.kernelSql, scopeId);
2081
+ forgetSwitchesOf(this.kernelSql, scopeId);
2012
2082
  this.forgetLifecycleDeliveries('scope_id = ?', scopeId);
2013
2083
  this.sql.exec('DELETE FROM scopes WHERE scope_id = ?', scopeId);
2014
2084
  });
@@ -2075,7 +2145,7 @@ export class ControlPlaneDO extends DurableObject {
2075
2145
  .exec('SELECT org_id FROM orgs WHERE tenant_id = ? AND slug = ?', tenantId, slug)
2076
2146
  .toArray()[0];
2077
2147
  if (slugOwner) {
2078
- throw new Error(`org slug '${slug}' already taken by ${slugOwner.org_id} (slugs are unique per tenant)`);
2148
+ throw substratError('conflict', `org slug '${slug}' already taken by ${slugOwner.org_id} (slugs are unique per tenant)`);
2079
2149
  }
2080
2150
  this.sql.exec('INSERT INTO orgs (org_id, tenant_id, slug, name, created_at) VALUES (?, ?, ?, ?, ?)', orgId, tenantId, slug, name, createdAt);
2081
2151
  return true;
@@ -2085,13 +2155,89 @@ export class ControlPlaneDO extends DurableObject {
2085
2155
  .exec('SELECT * FROM orgs WHERE tenant_id = ? ORDER BY slug', tenantId)
2086
2156
  .toArray();
2087
2157
  }
2158
+ /** The tenant-level reads, synchronous, for a bound asked inside one of this object's units (#1184). */
2159
+ directoryReader = {
2160
+ now: () => new Date().toISOString(),
2161
+ tenantTuples: (tenantId, subject, prefix) => this.tenantTuples(tenantId, subject, prefix),
2162
+ getRole: (tenantId, key) => this.getRole(tenantId, key),
2163
+ };
2164
+ /**
2165
+ * A membership change as ONE DO unit (#1184) — `HostAdmin.applyMembership`. Synchronous, so
2166
+ * no other request into this object runs between its reads and its writes: an add's removal
2167
+ * fence, then the bound — `boundedBy` covering every permission `roleKey` carries, against the
2168
+ * tuples and the role as they stand now (`tenantCoverage`) — then the write: the TENANT role
2169
+ * and its audit row for an add, `revokeAndFence`'s three for a removal. The coordinator mints
2170
+ * the rows (id, actor, attribution, `causedBy`): the role's, and the org's when the change
2171
+ * names one (#2047) — then the org is bounded here too (`boundedBy` a live member of it) and
2172
+ * joined or left in the same unit, a join expiring no later than `boundedBy`'s own membership.
2173
+ * The org row's `before`/`after` are written here, since only the unit knows the expiry.
2174
+ */
2175
+ applyMembership(change, row, orgRow) {
2176
+ const { tenantId, principal, roleKey, boundedBy, orgId } = change;
2177
+ return this.ctx.storage.transactionSync(() => {
2178
+ if (change.op === 'add') {
2179
+ const fence = this.sql.exec(MEMBERSHIP_FENCE_SINCE_SQL, tenantId, principal, change.unlessRemovedSince).toArray()[0];
2180
+ if (fence)
2181
+ return { applied: false, removedAt: fence.removed_at };
2182
+ }
2183
+ const role = this.getRole(tenantId, roleKey);
2184
+ if (!role)
2185
+ return { applied: false, unknownRole: roleKey };
2186
+ const bound = tenantCoverage(this.directoryReader, tenantId, boundedBy, role.permissions);
2187
+ if (!bound.covered)
2188
+ return { applied: false, missing: bound.missing };
2189
+ // #2047: the org, bounded by `boundedBy`'s own live membership of it.
2190
+ const org = orgId ? { orgId, row: orgRow, ...orgChangeBound(this.directoryReader, tenantId, { ...change, orgId }) } : undefined;
2191
+ if (org) {
2192
+ if (!org.row)
2193
+ throw new Error('applyMembership: an org change needs its audit row');
2194
+ if (!this.readOrg(tenantId, org.orgId))
2195
+ return { applied: false, unknownOrg: org.orgId };
2196
+ if (!org.bounded)
2197
+ return { applied: false, notMember: org.orgId };
2198
+ }
2199
+ if (change.op === 'remove') {
2200
+ const roleTaken = this.revokeAndFenceIn(tenantId, principal, `role:${roleKey}`, `tenant:${tenantId}`, row);
2201
+ const orgLeft = !!org && this.revokeAndFenceIn(tenantId, principal, 'member', `org:${org.orgId}`, { ...org.row, before: { principal, orgId: org.orgId } });
2202
+ return { applied: true, changed: roleTaken || orgLeft };
2203
+ }
2204
+ const at = new Date().toISOString();
2205
+ this.writeTenantTuple(tenantId, `principal:${principal}`, `role:${roleKey}`, `tenant:${tenantId}`, null);
2206
+ this.recordAdmin({ ...row, at });
2207
+ if (org?.bounded) {
2208
+ this.writeTenantTuple(tenantId, `principal:${principal}`, 'member', `org:${org.orgId}`, org.expiresAt);
2209
+ this.recordAdmin({ ...org.row, after: memberAddedAudit(principal, org.orgId, org.expiresAt), at });
2210
+ }
2211
+ return { applied: true };
2212
+ });
2213
+ }
2214
+ /**
2215
+ * A tenant-level removal as ONE DO unit (#1184) — `unassignRole` at the tenant node, and
2216
+ * `removeMember`: the tombstone, the principal's removal fence, and the audit row, which
2217
+ * K-21 writes only when something changed. The fence is raised either way: a removal of
2218
+ * someone whose add is still on its way takes nothing, and must still win. One method, so
2219
+ * no add can run between the revoke and the fence. Returns whether anything changed.
2220
+ */
2221
+ revokeAndFence(tenantId, principal, relation, object, row) {
2222
+ return this.ctx.storage.transactionSync(() => this.revokeAndFenceIn(tenantId, principal, relation, object, row));
2223
+ }
2088
2224
  /**
2089
- * Tombstone a membership (K-21), never DELETE. Guarded on `revoked_at IS NULL`
2090
- * so a repeat revoke neither moves the timestamp nor produces a second audit
2091
- * row. Returns whether it changed, so the coordinator can skip the audit write.
2225
+ * `revokeAndFence`'s writes, for inside a transaction a caller already holds.
2226
+ *
2227
+ * The time is minted HERE, in the unit, never taken from `row.at`: the coordinator mints its
2228
+ * row before an awaited RPC, and a queue delay in between would age the fence by however
2229
+ * long the call waited, which an add's cutoff would read as a removal from before its window
2230
+ * (Codex round 3). The fence is compared with an add's `occurredAt`, stamped by the ScopeDO's
2231
+ * clock. Both are this platform's own NTP-disciplined time, sub-second apart, which is far
2232
+ * inside `MEMBERSHIP_REMOVAL_SKEW_MS`.
2092
2233
  */
2093
- revokeMember(tenantId, subject, object, at) {
2094
- return this.revokeTenantTuple(tenantId, subject, 'member', object, at);
2234
+ revokeAndFenceIn(tenantId, principal, relation, object, row) {
2235
+ const at = new Date().toISOString();
2236
+ const changed = this.revokeTenantTuple(tenantId, `principal:${principal}`, relation, object, at);
2237
+ this.sql.exec(RAISE_MEMBERSHIP_FENCE_SQL, tenantId, principal, at);
2238
+ if (changed)
2239
+ this.recordAdmin({ ...row, at });
2240
+ return changed;
2095
2241
  }
2096
2242
  /** Tombstone any tenant tuple by its exact (subject, relation, object). Returns
2097
2243
  * whether a live row changed, so a repeat revoke is a silent no-op. */
@@ -2111,7 +2257,7 @@ export class ControlPlaneDO extends DurableObject {
2111
2257
  /** Members of an org. Live only unless `includeRevoked` — revoked rows are evidence. */
2112
2258
  listMembers(tenantId, object, includeRevoked) {
2113
2259
  return this.sql
2114
- .exec(`SELECT subject, revoked_at FROM _substrat_tenant_tuples
2260
+ .exec(`SELECT subject, revoked_at, expires_at FROM _substrat_tenant_tuples
2115
2261
  WHERE tenant_id = ? AND relation = 'member' AND object = ?
2116
2262
  ${includeRevoked ? '' : 'AND revoked_at IS NULL'}
2117
2263
  ORDER BY subject`, tenantId, object)
@@ -2248,7 +2394,7 @@ export class ControlPlaneDO extends DurableObject {
2248
2394
  if (existing) {
2249
2395
  if (existing.topology === topology && existing.tenant_id === tenantId)
2250
2396
  return false;
2251
- throw new Error(`identity pool '${provider}' is already registered as ${existing.topology}` +
2397
+ throw substratError('conflict', `identity pool '${provider}' is already registered as ${existing.topology}` +
2252
2398
  `${existing.tenant_id ? ` for tenant ${existing.tenant_id}` : ''}`);
2253
2399
  }
2254
2400
  this.sql.exec('INSERT INTO _substrat_identity_pools (provider, topology, tenant_id, created_at) VALUES (?, ?, ?, ?)', provider, topology, tenantId, createdAt);
@@ -2451,38 +2597,74 @@ export class ControlPlaneDO extends DurableObject {
2451
2597
  granted_by, granted_at, revoked_at)
2452
2598
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`, row.connectionId, row.tenantId, row.vertical, row.permission, row.scopeId, row.expiresAt, row.grantedBy, row.grantedAt);
2453
2599
  }
2454
- // -- the schedule switch's record (#1674) — `system-switch-record.ts` is the whole rule ---
2600
+ // -- the kill switches' record (#1674, #2029) — `system-switch-record.ts` is the whole rule ---
2455
2601
  /** OFF's write, before the scope's switch moves (#1823). Answers the row as it was. */
2456
- recordSystemSwitchedOff(row) {
2457
- return recordSystemSwitchedOff(this.kernelSql, row);
2602
+ recordSwitchedOff(row) {
2603
+ return this.writeSwitchIntent(row, recordSwitchedOff);
2458
2604
  }
2459
- /** ON's write, before the scope moves. Answers the row as it was, for a failed ON to restore. */
2460
- recordSystemSwitchedOn(row) {
2461
- return recordSystemSwitchedOn(this.kernelSql, row);
2605
+ /** ON's write, before the scope moves. Answers the row as it was, for a call that held nothing to restore. */
2606
+ recordSwitchedOn(row) {
2607
+ return this.writeSwitchIntent(row, recordSwitchedOn);
2608
+ }
2609
+ /**
2610
+ * #2045 (Codex r3): a switch call's record write is also its write-ahead intent, in ONE
2611
+ * transaction: the record, the subject's owed mark under this call's id, and the scope's reconcile
2612
+ * receipt cleared, so the sweep reconciles it and its re-assert settles the mark. Nothing that
2613
+ * happens to the move after this (a throw, a lost answer, a failed clear) can leave the scope
2614
+ * apart from its record with no mark to repair it. A write a newer call's row refuses writes
2615
+ * nothing at all.
2616
+ */
2617
+ writeSwitchIntent(row, write) {
2618
+ return this.ctx.storage.transactionSync(() => {
2619
+ const prior = write(this.kernelSql, row);
2620
+ if (recordWriteSuperseded(prior, row))
2621
+ return prior;
2622
+ markSwitchOwed(this.kernelSql, row.kind, row.tenantId, row.scopeId, row.key, row.operationId);
2623
+ this.sql.exec('UPDATE scopes SET provisioned_version_id = NULL WHERE scope_id = ?', row.scopeId);
2624
+ return prior;
2625
+ });
2462
2626
  }
2463
- /** Put a row back as the switch call's record write found it — the move that followed it failed. */
2464
- restoreSystemSwitchRecord(key, prior) {
2465
- restoreSystemSwitchRecord(this.kernelSql, key, prior);
2627
+ /**
2628
+ * Put a row back as the switch call's record write found it — the move that followed it held
2629
+ * nothing. #2045: with `clearOwed`, the call's own owed mark goes in the same transaction. The
2630
+ * row put back carries the PRIOR call's operation id, which is the fence every later re-assert
2631
+ * confirms under, and a mark newer than that fence is never cleared by one; so a mark left behind
2632
+ * by a separate clear that failed would keep the scope un-receipted on every pass.
2633
+ */
2634
+ restoreSwitchRecord(key, prior, clearOwed = false) {
2635
+ this.ctx.storage.transactionSync(() => {
2636
+ restoreSwitchRecord(this.kernelSql, key, prior);
2637
+ if (clearOwed)
2638
+ clearSwitchOwed(this.kernelSql, key.kind, key.tenantId, key.scopeId, key.key, key.operationId);
2639
+ });
2466
2640
  }
2467
2641
  /**
2468
- * #1823: which of these modules the tenant holds a live TENANT-level grant for — what the
2469
- * switch passes the scope as `tenantHeld`, so a module whose only authority there is a tenant
2470
- * tuple is still switchable. Read here, where the tenant tuples live.
2642
+ * #1823 (#2030 for a peer): which of these subjects the tenant holds a live TENANT-level grant
2643
+ * for — what the switch passes the scope as `tenantHeld`, so a subject whose only authority
2644
+ * there is a tenant tuple is still switchable. Read here, where the tenant tuples live.
2471
2645
  */
2472
- tenantHeldSystemModules(tenantId, moduleIds, now) {
2473
- return moduleIds.filter((m) => tenantHoldsSystemGrant(this.kernelSql, tenantId, m, now));
2646
+ tenantHeldOf(kind, tenantId, keys, now) {
2647
+ return tenantHeldOf(this.kernelSql, kind, tenantId, keys, now);
2474
2648
  }
2475
2649
  /** The fleet read. */
2476
2650
  listSystemSwitches(filter) {
2477
2651
  return listSystemSwitchRecords(this.kernelSql, filter);
2478
2652
  }
2479
- /** One scope's recorded positions, by module — the status read's `recorded` join. */
2480
- systemSwitchRecordsOf(tenantId, scopeId) {
2481
- return [...systemSwitchRecordsOf(this.kernelSql, tenantId, scopeId)];
2653
+ /** One scope's recorded positions of one kind, by subject — the status read's `recorded` join. */
2654
+ switchRecordsOf(kind, tenantId, scopeId) {
2655
+ return [...switchRecordsOf(this.kernelSql, kind, tenantId, scopeId)];
2656
+ }
2657
+ /** #2045: each record row's position and fence, by subject, read together — `switchRecordStatesOf`. */
2658
+ switchRecordStatesOf(kind, tenantId, scopeId) {
2659
+ return [...switchRecordStatesOf(this.kernelSql, kind, tenantId, scopeId)];
2482
2660
  }
2483
- /** The modules a re-assert switches back off on one scope. */
2484
- switchedOffModulesOf(tenantId, scopeId) {
2485
- return switchedOffModulesOf(this.kernelSql, tenantId, scopeId);
2661
+ /** #2045: the subjects of one kind owed a re-assert on one scope. */
2662
+ switchesOwedOf(kind, tenantId, scopeId) {
2663
+ return [...switchesOwedOf(this.kernelSql, kind, tenantId, scopeId)];
2664
+ }
2665
+ /** #2045: the scope confirmed a move of this subject under `fence` — `clearSwitchOwed`. */
2666
+ clearSwitchOwed(kind, tenantId, scopeId, key, fence) {
2667
+ clearSwitchOwed(this.kernelSql, kind, tenantId, scopeId, key, fence);
2486
2668
  }
2487
2669
  /** The tenant's LIVE connection grants (#592) — the provision/reconcile gather read. */
2488
2670
  listConnectionGrants(tenantId) {
@@ -2546,7 +2728,7 @@ export class ControlPlaneDO extends DurableObject {
2546
2728
  if (existing) {
2547
2729
  if (existing.principal_id === principal)
2548
2730
  return false;
2549
- throw new Error(`identity ${provider}:${externalId} in tenant ${tenantId} is already bound to ${existing.principal_id}`);
2731
+ throw substratError('conflict', `identity ${provider}:${externalId} in tenant ${tenantId} is already bound to ${existing.principal_id}`);
2550
2732
  }
2551
2733
  this.sql.exec(`INSERT INTO _substrat_identities
2552
2734
  (provider, external_id, principal_id, tenant_id, scope_id, created_at)
@@ -2871,35 +3053,51 @@ export class ControlPlaneDO extends DurableObject {
2871
3053
  SELECT ?, ?, ?, ? WHERE ${STILL_DELIVERED}`, scopeId, delivered, at, tenantRecorded ? 1 : 0, scopeId);
2872
3054
  }
2873
3055
  recordOpsFailure(row) {
2874
- this.sql.exec(`INSERT INTO _substrat_ops_failures
2875
- (id, actor, operation, stage, tenant_id, scope_id, vertical, version, status, message, reference, origin, code, fingerprint, at)
2876
- 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);
2877
- // Prune-on-write (#559): the retention lives HERE, not in a cron — every insert
2878
- // pays for its own housekeeping, so the table stays bounded even on a deployment
2879
- // whose scheduled pass is broken (the exact circumstance this table records).
2880
- const horizon = new Date(Date.now() - OPS_FAILURE_RETENTION_DAYS * 86_400_000).toISOString();
2881
- this.sql.exec('DELETE FROM _substrat_ops_failures WHERE at < ?', horizon);
2882
- // The issues materialization (#1233): the group's counters live on their own
2883
- // row, bumped in the same call, because the evidence self-prunes above and a
2884
- // count must survive its own exemplars. A fresh arrival regresses a resolved
2885
- // issue; an ignored one stays ignored — that is what ignoring means.
2886
- if (row.fingerprint !== null) {
2887
- this.sql.exec(`INSERT INTO _substrat_issues
2888
- (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)
2889
- VALUES (?, ?, ?, ?, ?, 'new', 1, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
2890
- ON CONFLICT (fingerprint) DO UPDATE SET
2891
- seen_count = seen_count + 1,
2892
- last_seen = excluded.last_seen,
2893
- last_message = excluded.last_message,
2894
- last_tenant_id = excluded.last_tenant_id,
2895
- last_owner_kind = excluded.last_owner_kind,
2896
- last_vertical = COALESCE(excluded.last_vertical, last_vertical),
2897
- last_version = COALESCE(excluded.last_version, last_version),
2898
- origin = COALESCE(excluded.origin, origin),
2899
- 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);
2900
- const issueHorizon = new Date(Date.now() - ISSUE_RETENTION_DAYS * 86_400_000).toISOString();
2901
- this.sql.exec('DELETE FROM _substrat_issues WHERE last_seen < ?', issueHorizon);
2902
- }
3056
+ // #1748: the evidence, its issue and its finding in ONE unit — a detector that throws takes
3057
+ // 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);
3099
+ }
3100
+ });
2903
3101
  }
2904
3102
  /**
2905
3103
  * #1632: subject erasure's directory half — the failure text this directory keeps about a
@@ -2985,14 +3183,35 @@ export class ControlPlaneDO extends DurableObject {
2985
3183
  }));
2986
3184
  }
2987
3185
  recordSweepRun(row) {
2988
- this.sql.exec(`INSERT OR IGNORE INTO _substrat_sweep_runs
2989
- (id, kind, unit, outcome, tenant_id, scope_id, vertical, version, operation,
2990
- connection_id, error, elapsed_ms, request_id, event_type, observed_at, platform_requests, at)
2991
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, row.id, row.kind, row.unit, row.outcome, row.tenant_id, row.scope_id, row.vertical, row.version, row.operation, row.connection_id, row.error, row.elapsed_ms, row.request_id, row.event_type, row.observed_at, row.platform_requests, row.at);
2992
- // Prune-on-write, like ops failures and for the same reason: bounded even on a
2993
- // deployment whose scheduled pass is broken.
2994
- const horizon = new Date(Date.now() - SWEEP_RUN_RETENTION_DAYS * 86_400_000).toISOString();
2995
- this.sql.exec('DELETE FROM _substrat_sweep_runs WHERE at < ?', horizon);
3186
+ // #1748: the row and its finding in ONE unit, so a replayed drain the unique index ignores
3187
+ // never meets a row whose observation did not commit.
3188
+ this.ctx.storage.transactionSync(() => {
3189
+ const { rowsWritten } = this.sql.exec(`INSERT OR IGNORE INTO _substrat_sweep_runs
3190
+ (id, kind, unit, outcome, tenant_id, scope_id, vertical, version, operation,
3191
+ connection_id, error, elapsed_ms, request_id, event_type, observed_at, platform_requests, at)
3192
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, row.id, row.kind, row.unit, row.outcome, row.tenant_id, row.scope_id, row.vertical, row.version, row.operation, row.connection_id, row.error, row.elapsed_ms, row.request_id, row.event_type, row.observed_at, row.platform_requests, row.at);
3193
+ // Prune-on-write, like ops failures and for the same reason: bounded even on a
3194
+ // deployment whose scheduled pass is broken.
3195
+ const horizon = new Date(Date.now() - SWEEP_RUN_RETENTION_DAYS * 86_400_000).toISOString();
3196
+ this.sql.exec('DELETE FROM _substrat_sweep_runs WHERE at < ?', horizon);
3197
+ // #1748: a failed schedule (Invariant) or a stale freshness verdict (Drift). Only when the
3198
+ // row was written: a replayed drain the unique index ignored is not a second occurrence.
3199
+ const finding = rowsWritten > 0
3200
+ ? findingOfSweepRun({
3201
+ kind: row.kind,
3202
+ unit: row.unit,
3203
+ outcome: row.outcome,
3204
+ tenantId: row.tenant_id,
3205
+ scopeId: row.scope_id,
3206
+ vertical: row.vertical,
3207
+ version: row.version,
3208
+ operation: row.operation,
3209
+ eventType: row.event_type,
3210
+ })
3211
+ : null;
3212
+ if (finding)
3213
+ observeFinding(doRedactionSql(this.sql), finding, row.at);
3214
+ });
2996
3215
  }
2997
3216
  listSweepRuns(query) {
2998
3217
  const where = [];
@@ -3062,6 +3281,47 @@ export class ControlPlaneDO extends DurableObject {
3062
3281
  }
3063
3282
  return pruned;
3064
3283
  }
3284
+ /** #1748: the findings retention pass, in one unit with its audit rows. */
3285
+ pruneFindings(limit, audit) {
3286
+ assertRowLimit('limit', limit);
3287
+ return this.audited(audit, (sql, write) => pruneFindings(sql, Date.now(), limit, write));
3288
+ }
3289
+ /**
3290
+ * #1748: a findings mutation and the audit rows it hands back, in ONE unit, so an audit write
3291
+ * that fails rolls the mutation back. `audit` is the row the host minted, with its actor and
3292
+ * attribution; each row the mutation hands back is written as a copy under its own id.
3293
+ */
3294
+ audited(audit, run) {
3295
+ return this.ctx.storage.transactionSync(() => run(doRedactionSql(this.sql), (a) => this.recordAdmin({
3296
+ ...audit,
3297
+ id: ulid(),
3298
+ action: a.action,
3299
+ tenantId: a.target.tenantId,
3300
+ vertical: a.target.vertical,
3301
+ before: a.before,
3302
+ after: a.after,
3303
+ })));
3304
+ }
3305
+ /** #1748: findings, most recently seen first — `listFindings`. */
3306
+ listFindings(filter) {
3307
+ return listFindings(doRedactionSql(this.sql), filter);
3308
+ }
3309
+ /** #1748: a verdict on one tenant finding, audited in the same unit. Undefined for an unknown one. */
3310
+ setFindingStatus(tenantId, id, status, at, audit) {
3311
+ return this.audited(audit, (sql, write) => setFindingStatus(sql, tenantId, id, status, at, write));
3312
+ }
3313
+ /** #1748: a suppress rule, applied to the findings it covers now, audited in the same unit. */
3314
+ createFindingRule(tenantId, input, createdBy, at, audit) {
3315
+ return this.audited(audit, (sql, write) => createFindingRule(sql, tenantId, input, createdBy, at, write));
3316
+ }
3317
+ /** #1748: end a rule now, audited in the same unit. */
3318
+ revokeFindingRule(tenantId, ruleId, at, audit) {
3319
+ return this.audited(audit, (sql, write) => revokeFindingRule(sql, tenantId, ruleId, at, write));
3320
+ }
3321
+ /** #1748: a tenant's suppress rules. */
3322
+ listFindingRules(tenantId, activeAt, limit) {
3323
+ return listFindingRules(doRedactionSql(this.sql), tenantId, activeAt, limit);
3324
+ }
3065
3325
  listIssues(query) {
3066
3326
  const where = [];
3067
3327
  const params = [];