@substrat-run/adapter-cloudflare 0.136.0 → 0.138.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, 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, tenantCoverage, 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, 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,
@@ -71,6 +72,39 @@ function keysetTail(where, params, key, page, defaultOrder = 'asc') {
71
72
  }
72
73
  return tail;
73
74
  }
75
+ /**
76
+ * The directory spine, as this adapter builds it — one of two hand-written copies (#969).
77
+ *
78
+ * The other is `applyDirectorySchema()` in `adapter-sqlite/src/index.ts`, and the two must
79
+ * describe the same schema: this side is production, that side is dev, CI, self-host and
80
+ * escrow. So a new directory or `_substrat_*` table both adapters need, or a new column on
81
+ * a table they both build, is added HERE and THERE — and for a directory created before it,
82
+ * to both column-addition lists as well (`ensureDirectoryColumns` below, and the pure
83
+ * adapter's method of the same name).
84
+ *
85
+ * `pnpm lint:spine-ddl` (`tools/spine-ddl-drift.mjs`) is what refuses a divergence: it
86
+ * executes each side's DDL plus those later ALTERs and compares the schemas a query would
87
+ * actually meet — columns, indexes (including the ones a UNIQUE creates) and foreign keys.
88
+ * Two things it does NOT judge. A table present on one side only is a note, not a failure,
89
+ * because the adapters legitimately partition the spine differently (this one projects
90
+ * tenant tuples, roles and entitlements into each scope where the pure adapter keeps one
91
+ * shared directory). And triggers and CHECK constraints are not compared at all; the spine
92
+ * has none today, so adding one here means adding it there with nothing to catch you.
93
+ * Keeping the copies and gating them, rather than moving the DDL into the kernel, is the
94
+ * recorded answer to #969; `docs/architecture/kernel-design.md` §8 says why.
95
+ */
96
+ /** #1713: one hosted scope, its directory lifecycle, and what its deployment last acknowledged. */
97
+ /**
98
+ * #2016, Codex #2037 r2: the condition every delivery-bookkeeping write carries, in its own statement —
99
+ * the scope (bound as the last parameter) still exists, is not reaped, and its tenant is not reaped.
100
+ * A heal selects its targets, then writes its asks and its receipts after a network round trip; a
101
+ * delete or a reap that commits in between has already forgotten the scope's rows
102
+ * (`forgetLifecycleDeliveries`), and an unconditional write would bring them back as orphans.
103
+ */
104
+ const STILL_DELIVERED = `EXISTS (
105
+ SELECT 1 FROM scopes s JOIN tenants t ON t.tenant_id = s.tenant_id
106
+ WHERE s.scope_id = ? AND s.status <> 'reaped' AND t.status <> 'reaped'
107
+ )`;
74
108
  const DIRECTORY_DDL = `
75
109
  -- The ';' in this comment is a deliberate tripwire; the DDL must go through
76
110
  -- splitSqlStatements, and a naive split(';') fails the directory at construction.
@@ -423,6 +457,9 @@ const DIRECTORY_DDL = `
423
457
  );
424
458
  ${IMPERSONATION_DDL}
425
459
  ${SYSTEM_SWITCHES_DDL}
460
+ ${MEMBERSHIP_FENCES_DDL}
461
+ ${PEER_SWITCHES_DDL}
462
+ ${SWITCH_OWED_DDL}
426
463
  CREATE TABLE IF NOT EXISTS _substrat_admin_log (
427
464
  id TEXT PRIMARY KEY,
428
465
  actor TEXT NOT NULL,
@@ -573,10 +610,21 @@ const DIRECTORY_DDL = `
573
610
  -- directory, so the platform delivers a scope's lifecycle to it, and the heal sweep
574
611
  -- re-delivers wherever this differs from the directory. No row reads as active/active,
575
612
  -- which is what a deployment holding no lifecycle runs as.
613
+ -- #2016: tenant_recorded, whether that deployment answered that the scope holds a record of its
614
+ -- tenant (1) or not (0); NULL is never asked. The heal keeps delivering to any served scope, a
615
+ -- copy as much as a primary, until it answers 1.
576
616
  CREATE TABLE IF NOT EXISTS scope_lifecycle_receipts (
577
617
  scope_id TEXT PRIMARY KEY,
578
618
  delivered TEXT NOT NULL,
579
- at TEXT NOT NULL
619
+ at TEXT NOT NULL,
620
+ tenant_recorded INTEGER
621
+ );
622
+ -- #2016: when the heal last asked a scope for its tenant record, whatever came of it. Kept apart
623
+ -- from the receipt, which only an answer writes, so a scope whose deployment never answers still
624
+ -- rotates behind the rest of the walk instead of heading it every pass.
625
+ CREATE TABLE IF NOT EXISTS scope_tenant_asks (
626
+ scope_id TEXT PRIMARY KEY,
627
+ asked_at TEXT NOT NULL
580
628
  );
581
629
  -- #1713: the lifecycle's revisions, one counter per subject ("scope:<id>" counts a
582
630
  -- scope's status transitions, "tenant:<id>" a tenant's status changes), each bumped in
@@ -665,6 +713,19 @@ export const BACKFILL_FAILURES_KEY = 'versionMigrationsBackfillFailures';
665
713
  export function backfillBackoffMs(failures) {
666
714
  return Math.min(BACKFILL_PAUSE_MS * 2 ** failures, BACKFILL_BACKOFF_MAX_MS);
667
715
  }
716
+ /** The directory writes `ControlPlaneDO.reply` answers for (#113): each refuses with a typed throw. */
717
+ export const REPLIED_METHODS = [
718
+ 'createTenant',
719
+ 'setTenantStatus',
720
+ 'reapTenant',
721
+ 'setTenantName',
722
+ 'putTenantStore',
723
+ 'putBlobStore',
724
+ 'provisionScope',
725
+ 'createOrg',
726
+ 'registerIdentityPool',
727
+ 'linkIdentity',
728
+ ];
668
729
  export class ControlPlaneDO extends DurableObject {
669
730
  sql;
670
731
  /** The directory's store as the kernel's SQL handle — the switch record's helpers (#1674). */
@@ -748,20 +809,30 @@ export class ControlPlaneDO extends DurableObject {
748
809
  * construction. A directory restore runs `buildDirectorySchema` inside its own transaction
749
810
  * instead (`importDump`).
750
811
  *
751
- * The schedule switch's record (#1674) is backfilled from the admin log on a run that
752
- * creates its table, and its table is created in the SAME transaction as the backfill
753
- * (Copilot review): the gate is "the table does not exist yet", so a table committed ahead
754
- * of a backfill that then failed would read as already migrated on every later run. On
812
+ * Each kill switch's record (#1674, and #2029's for a peer) is backfilled from the admin log
813
+ * on a run that creates its table, and its table is created in the SAME transaction as the
814
+ * backfill (Copilot review): the gate is "the table does not exist yet", so a table committed
815
+ * ahead of a backfill that then failed would read as already migrated on every later run. On
755
816
  * such a run its statements are held back from the loop below and run with the backfill.
756
817
  */
757
818
  applyDirectorySchema() {
758
- const switchRecordIsNew = !systemSwitchesTableExists(this.kernelSql);
759
- this.buildDirectorySchema({ holdSwitchRecord: switchRecordIsNew });
760
- if (switchRecordIsNew) {
819
+ const newRecords = SWITCH_KINDS.filter((kind) => !switchesTableExists(this.kernelSql, kind));
820
+ const fencesAreNew = !membershipFencesTableExists(this.kernelSql);
821
+ this.buildDirectorySchema({ holdSwitchRecords: newRecords, holdFences: fencesAreNew });
822
+ for (const kind of newRecords) {
823
+ this.ctx.storage.transactionSync(() => {
824
+ for (const stmt of splitSqlStatements(switchesDdlOf(kind)))
825
+ this.sql.exec(stmt);
826
+ this.sql.exec(switchesBackfillSqlOf(kind));
827
+ });
828
+ }
829
+ // #1184: the removal fence and its backfill from the admin log, together, the same way.
830
+ if (fencesAreNew) {
761
831
  this.ctx.storage.transactionSync(() => {
762
- for (const stmt of splitSqlStatements(SYSTEM_SWITCHES_DDL))
832
+ for (const stmt of splitSqlStatements(MEMBERSHIP_FENCES_DDL))
763
833
  this.sql.exec(stmt);
764
- this.sql.exec(SYSTEM_SWITCHES_BACKFILL_SQL);
834
+ // Stamped now, inside this unit (`membershipFencesBackfillSql` says why).
835
+ this.sql.exec(membershipFencesBackfillSql(new Date().toISOString()));
765
836
  });
766
837
  }
767
838
  }
@@ -769,12 +840,15 @@ export class ControlPlaneDO extends DurableObject {
769
840
  * The directory's tables as this code builds them, carrying whatever the directory already
770
841
  * holds forward to that shape: the construction's pass, and a restore's (#1898, #1912), which
771
842
  * runs it inside its transaction onto an emptied directory, before loading any row.
772
- * `holdSwitchRecord` leaves the #1674 record's statements to the caller, which creates the
773
- * table with its backfill.
843
+ * `holdSwitchRecords` leaves those kinds' record statements (#1674, #2029) to the caller, which
844
+ * creates each table with its backfill; `holdFences` does the same for #1184's removal fence.
774
845
  */
775
- buildDirectorySchema({ holdSwitchRecord }) {
846
+ buildDirectorySchema({ holdSwitchRecords, holdFences = false }) {
847
+ const held = holdSwitchRecords.map(switchesTableOf);
776
848
  for (const stmt of DIRECTORY_DDL_PLAN.loop) {
777
- if (holdSwitchRecord && stmt.includes(SYSTEM_SWITCHES_TABLE))
849
+ if (held.some((table) => stmt.includes(table)))
850
+ continue;
851
+ if (holdFences && stmt.includes(MEMBERSHIP_FENCES_TABLE))
778
852
  continue;
779
853
  this.sql.exec(stmt);
780
854
  }
@@ -930,6 +1004,8 @@ export class ControlPlaneDO extends DurableObject {
930
1004
  }
931
1005
  // §4.8's grace-window timestamp on tenants (mirrors scopes' archived_at).
932
1006
  this.addColumn('tenants', 'deleting_at TEXT');
1007
+ // #2016: whether a scope's deployment holds a record of its tenant (see the table's DDL).
1008
+ this.addColumn('scope_lifecycle_receipts', 'tenant_recorded INTEGER');
933
1009
  // #412 provenance: the manager tenant that provisioned this one (NULL = direct staff).
934
1010
  this.addColumn('tenants', 'provisioned_by_tenant TEXT REFERENCES tenants(tenant_id)');
935
1011
  // §4.7 custom-hostname issuance: CF's hostname id + the DNS records to publish (JSON).
@@ -1089,12 +1165,13 @@ export class ControlPlaneDO extends DurableObject {
1089
1165
  // Every table from this code's DDL, onto an empty directory, so none of the pass's
1090
1166
  // rebuilds fires (each would reach `transactionSync`, which this async transaction cannot
1091
1167
  // hold): every table is created on the current shape.
1092
- this.buildDirectorySchema({ holdSwitchRecord: false });
1168
+ this.buildDirectorySchema({ holdSwitchRecords: [] });
1093
1169
  // Refuses a table this code does not build, adds unknown columns bare, loads the rows by
1094
1170
  // name, then the legacy scope and #1674 backfills, all inside this transaction.
1095
1171
  loadDirectoryDump(tables, {
1096
1172
  columnsOf: (name) => doBuiltColumnsOf(this.sql, name),
1097
1173
  exec: (sql) => this.sql.exec(sql),
1174
+ now: () => new Date().toISOString(),
1098
1175
  maxColumns: DO_SQL_LIMITS.columns,
1099
1176
  insert: (sql, rows) => {
1100
1177
  for (const row of rows)
@@ -1153,7 +1230,7 @@ export class ControlPlaneDO extends DurableObject {
1153
1230
  .exec('SELECT tenant_id FROM tenants WHERE slug = ?', slug)
1154
1231
  .toArray()[0];
1155
1232
  if (slugOwner) {
1156
- throw new Error(`tenant slug '${slug}' already taken by ${slugOwner.tenant_id} (slugs are unique)`);
1233
+ throw substratError('conflict', `tenant slug '${slug}' already taken by ${slugOwner.tenant_id} (slugs are unique)`);
1157
1234
  }
1158
1235
  this.sql.exec(`INSERT INTO tenants (tenant_id, slug, name, status, created_at, provisioned_by_tenant)
1159
1236
  VALUES (?, ?, ?, 'active', ?, ?)`, id, slug, name, createdAt, provisionedByTenant);
@@ -1163,12 +1240,12 @@ export class ControlPlaneDO extends DurableObject {
1163
1240
  setTenantStatus(tenantId, status) {
1164
1241
  const before = this.readTenant(tenantId);
1165
1242
  if (!before)
1166
- throw new Error(`unknown tenant: ${tenantId}`);
1243
+ throw substratError('not_found', `unknown tenant: ${tenantId}`);
1167
1244
  // `reaped` is terminal and destroys data — unreachable here, only ever set by
1168
1245
  // reapTenant, so a plain status flip cannot forge a tombstone over live data
1169
1246
  // (§4.8, the tenant analogue of reapScope's archived-only gate).
1170
1247
  if (status === 'reaped') {
1171
- throw new Error(`tenant ${tenantId} cannot be set to 'reaped' via setTenantStatus — reap goes through reapTenant (control-plane.md §4.8)`);
1248
+ throw substratError('validation_failed', `tenant ${tenantId} cannot be set to 'reaped' via setTenantStatus — reap goes through reapTenant (control-plane.md §4.8)`);
1172
1249
  }
1173
1250
  // Stamp/clear deleting_at so the grace-window sweep can age tenants (§4.8):
1174
1251
  // entering `deleting` stamps, leaving it (an un-delete) clears — the same shape
@@ -1195,9 +1272,9 @@ export class ControlPlaneDO extends DurableObject {
1195
1272
  reapTenant(tenantId) {
1196
1273
  const before = this.readTenant(tenantId);
1197
1274
  if (!before)
1198
- throw new Error(`unknown tenant: ${tenantId}`);
1275
+ throw substratError('not_found', `unknown tenant: ${tenantId}`);
1199
1276
  if (before.status !== 'deleting') {
1200
- throw new Error(`tenant ${tenantId} is ${before.status}, not deleting — only a deleting tenant may be reaped`);
1277
+ throw substratError('conflict', `tenant ${tenantId} is ${before.status}, not deleting — only a deleting tenant may be reaped`);
1201
1278
  }
1202
1279
  // KEPT as the tombstone: the `tenants` row itself and `_substrat_admin_log` (the
1203
1280
  // compliance witness — never swept). Scope rows were already reaped individually.
@@ -1209,11 +1286,16 @@ export class ControlPlaneDO extends DurableObject {
1209
1286
  '_substrat_entitlements', // per-tenant SKU flags
1210
1287
  'orgs', // K-22 org records
1211
1288
  '_substrat_system_switches', // #1674: the schedule switch's record, per scope
1289
+ '_substrat_membership_fences', // #1184: the latest removal, per principal
1290
+ '_substrat_peer_switches', // #2029: the peer switch's record, per scope
1291
+ '_substrat_switch_owed', // #2045: subjects owed a re-assert, per scope
1212
1292
  ]) {
1213
1293
  this.sql.exec(`DELETE FROM ${table} WHERE tenant_id = ?`, tenantId);
1214
1294
  }
1215
1295
  this.ctx.storage.transactionSync(() => {
1216
1296
  this.sql.exec("UPDATE tenants SET status = 'reaped', deleting_at = NULL WHERE tenant_id = ?", tenantId);
1297
+ // Its scopes were reaped one by one, each forgetting its own; any left over go here.
1298
+ this.forgetLifecycleDeliveries('scope_id IN (SELECT scope_id FROM scopes WHERE tenant_id = ?)', tenantId);
1217
1299
  this.bumpLifecycleRevision(`tenant:${tenantId}`); // #1713
1218
1300
  });
1219
1301
  return before.status;
@@ -1222,7 +1304,7 @@ export class ControlPlaneDO extends DurableObject {
1222
1304
  setTenantName(tenantId, name) {
1223
1305
  const before = this.readTenant(tenantId);
1224
1306
  if (!before)
1225
- throw new Error(`unknown tenant: ${tenantId}`);
1307
+ throw substratError('not_found', `unknown tenant: ${tenantId}`);
1226
1308
  this.sql.exec('UPDATE tenants SET name = ? WHERE tenant_id = ?', name, tenantId);
1227
1309
  return before.name;
1228
1310
  }
@@ -1256,10 +1338,10 @@ export class ControlPlaneDO extends DurableObject {
1256
1338
  .exec('SELECT status FROM tenants WHERE tenant_id = ?', row.tenantId)
1257
1339
  .toArray()[0];
1258
1340
  if (!tenantRow) {
1259
- throw new Error(`cannot provision tenant store under unknown tenant: ${row.tenantId}`);
1341
+ throw substratError('not_found', `cannot provision tenant store under unknown tenant: ${row.tenantId}`);
1260
1342
  }
1261
1343
  if (tenantRow.status !== 'active') {
1262
- throw new Error(`cannot provision tenant store under non-active tenant (status: ${tenantRow.status}): ${row.tenantId}`);
1344
+ throw substratError('conflict', `cannot provision tenant store under non-active tenant (status: ${tenantRow.status}): ${row.tenantId}`);
1263
1345
  }
1264
1346
  this.sql.exec(`INSERT OR IGNORE INTO tenant_stores (tenant_id, vertical, binding, kind, ref, created_at)
1265
1347
  VALUES (?, ?, ?, ?, ?, ?)`, row.tenantId, row.vertical, row.binding, row.kind, row.ref, row.createdAt);
@@ -1300,10 +1382,10 @@ export class ControlPlaneDO extends DurableObject {
1300
1382
  .exec('SELECT status FROM tenants WHERE tenant_id = ?', row.tenantId)
1301
1383
  .toArray()[0];
1302
1384
  if (!tenantRow) {
1303
- throw new Error(`cannot provision blob store under unknown tenant: ${row.tenantId}`);
1385
+ throw substratError('not_found', `cannot provision blob store under unknown tenant: ${row.tenantId}`);
1304
1386
  }
1305
1387
  if (tenantRow.status !== 'active') {
1306
- throw new Error(`cannot provision blob store under non-active tenant (status: ${tenantRow.status}): ${row.tenantId}`);
1388
+ throw substratError('conflict', `cannot provision blob store under non-active tenant (status: ${tenantRow.status}): ${row.tenantId}`);
1307
1389
  }
1308
1390
  this.sql.exec(`INSERT OR IGNORE INTO blob_stores (tenant_id, vertical, binding, kind, ref, created_at)
1309
1391
  VALUES (?, ?, ?, ?, ?, ?)`, row.tenantId, row.vertical, row.binding, row.kind, row.ref, row.createdAt);
@@ -1340,10 +1422,10 @@ export class ControlPlaneDO extends DurableObject {
1340
1422
  .exec('SELECT status FROM tenants WHERE tenant_id = ?', tenantId)
1341
1423
  .toArray()[0];
1342
1424
  if (!tenantRow) {
1343
- throw new Error(`cannot provision scope under unknown tenant: ${tenantId}`);
1425
+ throw substratError('conflict', `cannot provision scope under unknown tenant: ${tenantId}`);
1344
1426
  }
1345
1427
  if (tenantRow.status !== 'active') {
1346
- throw new Error(`cannot provision scope under non-active tenant (status: ${tenantRow.status}): ${tenantId}`);
1428
+ throw substratError('conflict', `cannot provision scope under non-active tenant (status: ${tenantRow.status}): ${tenantId}`);
1347
1429
  }
1348
1430
  // Idempotency is on the scope_id (§3.3: idempotent and journaled — safe to
1349
1431
  // re-run), so an existing scope short-circuits before the slug check:
@@ -1356,7 +1438,7 @@ export class ControlPlaneDO extends DurableObject {
1356
1438
  .exec("SELECT scope_id FROM scopes WHERE tenant_id = ? AND slug = ? AND status NOT IN ('archived', 'reaped')", tenantId, record.slug)
1357
1439
  .toArray()[0];
1358
1440
  if (slugOwner) {
1359
- throw new Error(`scope slug '${record.slug}' already taken under tenant ${tenantId} ` +
1441
+ throw substratError('conflict', `scope slug '${record.slug}' already taken under tenant ${tenantId} ` +
1360
1442
  `by ${slugOwner.scope_id} (slugs are unique within a tenant)`);
1361
1443
  }
1362
1444
  // A scope born while its vertical serves in place (#286) is born ON the serving
@@ -1438,6 +1520,21 @@ export class ControlPlaneDO extends DurableObject {
1438
1520
  return undefined;
1439
1521
  return row;
1440
1522
  }
1523
+ /**
1524
+ * A directory write, its refusal answered as DATA (#113): the typed throws in these methods
1525
+ * would reach the coordinator flattened, so it calls them through here and rethrows the
1526
+ * refusal with its code (`unwrapReply`). Called directly, they still throw — a coordinator
1527
+ * from before this change does — and that caller reads the code folded into the message.
1528
+ */
1529
+ async reply(method, args) {
1530
+ return replyOf(() => {
1531
+ // An RPC argument, so held to the list rather than trusted to be one of its names.
1532
+ if (!REPLIED_METHODS.includes(method))
1533
+ throw new Error(`not a replied directory method: ${String(method)}`);
1534
+ const run = this[method];
1535
+ return run.apply(this, args);
1536
+ });
1537
+ }
1441
1538
  /**
1442
1539
  * The getScope gate (control-plane.md §4.1/§4.2): validate the scope belongs
1443
1540
  * to the tenant and both records are active, or throw the fail-closed reason.
@@ -1454,11 +1551,10 @@ export class ControlPlaneDO extends DurableObject {
1454
1551
  * The same gate, answering its refusal as DATA (#1718). An error thrown in a Durable
1455
1552
  * Object arrives at the coordinator flattened, its code gone, so a typed throw here
1456
1553
  * would still reach the caller untyped (#1714 measured it). A record crosses intact, so
1457
- * the coordinator throws the refusal itself, typed. `code` is set only where a code is
1458
- * known; the lifecycle refusals keep their bare messages. Null means access is allowed.
1554
+ * the coordinator throws the refusal itself, typed (#113: every refusal but a failed
1555
+ * migration carries its code). Null means access is allowed.
1459
1556
  */
1460
1557
  scopeAccessRefusal(tenantId, scopeId) {
1461
- const refuse = (message) => ({ code: null, message });
1462
1558
  const row = this.sql
1463
1559
  .exec('SELECT tenant_id, status FROM scopes WHERE scope_id = ?', scopeId)
1464
1560
  .toArray()[0];
@@ -1469,10 +1565,14 @@ export class ControlPlaneDO extends DurableObject {
1469
1565
  .exec('SELECT status FROM tenants WHERE tenant_id = ?', tenantId)
1470
1566
  .toArray()[0];
1471
1567
  if (!tenantRow) {
1472
- return refuse(`scope has no tenant record: (${tenantId}, ${scopeId})`);
1568
+ return { code: 'not_found', message: `scope has no tenant record: (${tenantId}, ${scopeId})`, reason: SCOPE_GATE_REASONS.unrecorded };
1473
1569
  }
1474
1570
  if (tenantRow.status !== 'active') {
1475
- return refuse(`tenant not active (status: ${tenantRow.status}): ${tenantId}`);
1571
+ return {
1572
+ code: 'conflict',
1573
+ message: `tenant not active (status: ${tenantRow.status}): ${tenantId}`,
1574
+ reason: SCOPE_GATE_REASONS.notActive,
1575
+ };
1476
1576
  }
1477
1577
  if (row.status !== 'active') {
1478
1578
  // A scope stuck in provisioning because its migrations failed must say so.
@@ -1483,10 +1583,17 @@ export class ControlPlaneDO extends DurableObject {
1483
1583
  .exec('SELECT migration_failed_version, migration_error FROM scopes WHERE scope_id = ?', scopeId)
1484
1584
  .toArray()[0];
1485
1585
  if (failure?.migration_failed_version) {
1486
- return refuse(`migration failed for ${failure.migration_failed_version} — scope fails closed: ` +
1487
- `${failure.migration_error ?? 'unknown error'}`);
1586
+ return {
1587
+ code: null,
1588
+ message: `migration failed for ${failure.migration_failed_version} — scope fails closed: ` +
1589
+ `${failure.migration_error ?? 'unknown error'}`,
1590
+ };
1488
1591
  }
1489
- return refuse(`scope not active (status: ${row.status}): ${scopeId}`);
1592
+ return {
1593
+ code: 'conflict',
1594
+ message: `scope not active (status: ${row.status}): ${scopeId}`,
1595
+ reason: SCOPE_GATE_REASONS.notActive,
1596
+ };
1490
1597
  }
1491
1598
  return null;
1492
1599
  }
@@ -1521,7 +1628,7 @@ export class ControlPlaneDO extends DurableObject {
1521
1628
  if (!from.includes(row.status)) {
1522
1629
  return {
1523
1630
  ok: false,
1524
- code: null,
1631
+ code: 'conflict',
1525
1632
  message: `illegal scope transition for ${action}: ${row.status} → ${to} ` +
1526
1633
  `(allowed from: ${from.join('|')})`,
1527
1634
  };
@@ -1541,8 +1648,10 @@ export class ControlPlaneDO extends DurableObject {
1541
1648
  else {
1542
1649
  this.sql.exec('UPDATE scopes SET status = ? WHERE scope_id = ?', to, scopeId);
1543
1650
  }
1544
- if (to === 'reaped')
1545
- forgetSystemSwitchesOf(this.kernelSql, scopeId);
1651
+ if (to === 'reaped') {
1652
+ forgetSwitchesOf(this.kernelSql, scopeId);
1653
+ this.forgetLifecycleDeliveries('scope_id = ?', scopeId);
1654
+ }
1546
1655
  this.bumpLifecycleRevision(`scope:${scopeId}`); // #1713, with the status it counts
1547
1656
  });
1548
1657
  return { ok: true, status: row.status, vertical: row.vertical };
@@ -1948,6 +2057,11 @@ export class ControlPlaneDO extends DurableObject {
1948
2057
  * for a repair that failed, which is the one outcome that must keep being retried.
1949
2058
  */
1950
2059
  markScopeProvisioned(scopeId, versionId) {
2060
+ // #2045: a scope owed a switch re-assert keeps no receipt, so the sweep reconciles it again
2061
+ // until its re-assert has settled every mark — including a mark written while this
2062
+ // reconcile ran, after its re-assert had read none.
2063
+ if (versionId !== null && scopeOwesSwitch(this.kernelSql, scopeId))
2064
+ versionId = null;
1951
2065
  this.sql.exec('UPDATE scopes SET provisioned_version_id = ? WHERE scope_id = ?', versionId, scopeId);
1952
2066
  }
1953
2067
  /**
@@ -1956,9 +2070,24 @@ export class ControlPlaneDO extends DurableObject {
1956
2070
  * wiping the scope DO's storage — the fork-only refusal lives there, not here.
1957
2071
  */
1958
2072
  deleteScopeDirectory(scopeId) {
1959
- this.sql.exec('DELETE FROM hostnames WHERE scope_id = ?', scopeId);
1960
- forgetSystemSwitchesOf(this.kernelSql, scopeId);
1961
- this.sql.exec('DELETE FROM scopes WHERE scope_id = ?', scopeId);
2073
+ this.ctx.storage.transactionSync(() => {
2074
+ this.sql.exec('DELETE FROM hostnames WHERE scope_id = ?', scopeId);
2075
+ forgetSwitchesOf(this.kernelSql, scopeId);
2076
+ this.forgetLifecycleDeliveries('scope_id = ?', scopeId);
2077
+ this.sql.exec('DELETE FROM scopes WHERE scope_id = ?', scopeId);
2078
+ });
2079
+ }
2080
+ /**
2081
+ * #1713 / #2016: drop the delivery bookkeeping of scopes that will never be delivered to again —
2082
+ * what each deployment acknowledged (`scope_lifecycle_receipts`) and when the heal last asked it
2083
+ * for its tenant record (`scope_tenant_asks`). A hard-deleted fork's rows would otherwise outlive
2084
+ * it, and every preview created and deleted would grow both tables, and every directory dump, for
2085
+ * good. A reaped scope keeps its tombstone row but leaves every delivery walk, so its go too.
2086
+ */
2087
+ forgetLifecycleDeliveries(where, ...params) {
2088
+ for (const table of ['scope_lifecycle_receipts', 'scope_tenant_asks']) {
2089
+ this.sql.exec(`DELETE FROM ${table} WHERE ${where}`, ...params);
2090
+ }
1962
2091
  }
1963
2092
  readChannel(verticalSlug, channel) {
1964
2093
  return this.sql
@@ -2010,7 +2139,7 @@ export class ControlPlaneDO extends DurableObject {
2010
2139
  .exec('SELECT org_id FROM orgs WHERE tenant_id = ? AND slug = ?', tenantId, slug)
2011
2140
  .toArray()[0];
2012
2141
  if (slugOwner) {
2013
- throw new Error(`org slug '${slug}' already taken by ${slugOwner.org_id} (slugs are unique per tenant)`);
2142
+ throw substratError('conflict', `org slug '${slug}' already taken by ${slugOwner.org_id} (slugs are unique per tenant)`);
2014
2143
  }
2015
2144
  this.sql.exec('INSERT INTO orgs (org_id, tenant_id, slug, name, created_at) VALUES (?, ?, ?, ?, ?)', orgId, tenantId, slug, name, createdAt);
2016
2145
  return true;
@@ -2020,13 +2149,69 @@ export class ControlPlaneDO extends DurableObject {
2020
2149
  .exec('SELECT * FROM orgs WHERE tenant_id = ? ORDER BY slug', tenantId)
2021
2150
  .toArray();
2022
2151
  }
2152
+ /** The tenant-level reads, synchronous, for a bound asked inside one of this object's units (#1184). */
2153
+ directoryReader = {
2154
+ now: () => new Date().toISOString(),
2155
+ tenantTuples: (tenantId, subject, prefix) => this.tenantTuples(tenantId, subject, prefix),
2156
+ getRole: (tenantId, key) => this.getRole(tenantId, key),
2157
+ };
2023
2158
  /**
2024
- * Tombstone a membership (K-21), never DELETE. Guarded on `revoked_at IS NULL`
2025
- * so a repeat revoke neither moves the timestamp nor produces a second audit
2026
- * row. Returns whether it changed, so the coordinator can skip the audit write.
2159
+ * A membership change as ONE DO unit (#1184) — `HostAdmin.applyMembership`. Synchronous, so
2160
+ * no other request into this object runs between its reads and its writes: an add's removal
2161
+ * fence, then the bound — `boundedBy` covering every permission `roleKey` carries, against the
2162
+ * tuples and the role as they stand now (`tenantCoverage`) — then the write: the TENANT role
2163
+ * and its audit row for an add, `revokeAndFence`'s three for a removal. The coordinator mints
2164
+ * the row (id, actor, attribution, `causedBy`).
2027
2165
  */
2028
- revokeMember(tenantId, subject, object, at) {
2029
- return this.revokeTenantTuple(tenantId, subject, 'member', object, at);
2166
+ applyMembership(change, row) {
2167
+ const { tenantId, principal, roleKey, boundedBy } = change;
2168
+ return this.ctx.storage.transactionSync(() => {
2169
+ if (change.op === 'add') {
2170
+ const fence = this.sql.exec(MEMBERSHIP_FENCE_SINCE_SQL, tenantId, principal, change.unlessRemovedSince).toArray()[0];
2171
+ if (fence)
2172
+ return { applied: false, removedAt: fence.removed_at };
2173
+ }
2174
+ const role = this.getRole(tenantId, roleKey);
2175
+ if (!role)
2176
+ return { applied: false, unknownRole: roleKey };
2177
+ const bound = tenantCoverage(this.directoryReader, tenantId, boundedBy, role.permissions);
2178
+ if (!bound.covered)
2179
+ return { applied: false, missing: bound.missing };
2180
+ if (change.op === 'remove') {
2181
+ return { applied: true, changed: this.revokeAndFenceIn(tenantId, principal, `role:${roleKey}`, `tenant:${tenantId}`, row) };
2182
+ }
2183
+ this.writeTenantTuple(tenantId, `principal:${principal}`, `role:${roleKey}`, `tenant:${tenantId}`, null);
2184
+ this.recordAdmin({ ...row, at: new Date().toISOString() });
2185
+ return { applied: true };
2186
+ });
2187
+ }
2188
+ /**
2189
+ * A tenant-level removal as ONE DO unit (#1184) — `unassignRole` at the tenant node, and
2190
+ * `removeMember`: the tombstone, the principal's removal fence, and the audit row, which
2191
+ * K-21 writes only when something changed. The fence is raised either way: a removal of
2192
+ * someone whose add is still on its way takes nothing, and must still win. One method, so
2193
+ * no add can run between the revoke and the fence. Returns whether anything changed.
2194
+ */
2195
+ revokeAndFence(tenantId, principal, relation, object, row) {
2196
+ return this.ctx.storage.transactionSync(() => this.revokeAndFenceIn(tenantId, principal, relation, object, row));
2197
+ }
2198
+ /**
2199
+ * `revokeAndFence`'s writes, for inside a transaction a caller already holds.
2200
+ *
2201
+ * The time is minted HERE, in the unit, never taken from `row.at`: the coordinator mints its
2202
+ * row before an awaited RPC, and a queue delay in between would age the fence by however
2203
+ * long the call waited, which an add's cutoff would read as a removal from before its window
2204
+ * (Codex round 3). The fence is compared with an add's `occurredAt`, stamped by the ScopeDO's
2205
+ * clock. Both are this platform's own NTP-disciplined time, sub-second apart, which is far
2206
+ * inside `MEMBERSHIP_REMOVAL_SKEW_MS`.
2207
+ */
2208
+ revokeAndFenceIn(tenantId, principal, relation, object, row) {
2209
+ const at = new Date().toISOString();
2210
+ const changed = this.revokeTenantTuple(tenantId, `principal:${principal}`, relation, object, at);
2211
+ this.sql.exec(RAISE_MEMBERSHIP_FENCE_SQL, tenantId, principal, at);
2212
+ if (changed)
2213
+ this.recordAdmin({ ...row, at });
2214
+ return changed;
2030
2215
  }
2031
2216
  /** Tombstone any tenant tuple by its exact (subject, relation, object). Returns
2032
2217
  * whether a live row changed, so a repeat revoke is a silent no-op. */
@@ -2183,7 +2368,7 @@ export class ControlPlaneDO extends DurableObject {
2183
2368
  if (existing) {
2184
2369
  if (existing.topology === topology && existing.tenant_id === tenantId)
2185
2370
  return false;
2186
- throw new Error(`identity pool '${provider}' is already registered as ${existing.topology}` +
2371
+ throw substratError('conflict', `identity pool '${provider}' is already registered as ${existing.topology}` +
2187
2372
  `${existing.tenant_id ? ` for tenant ${existing.tenant_id}` : ''}`);
2188
2373
  }
2189
2374
  this.sql.exec('INSERT INTO _substrat_identity_pools (provider, topology, tenant_id, created_at) VALUES (?, ?, ?, ?)', provider, topology, tenantId, createdAt);
@@ -2386,30 +2571,74 @@ export class ControlPlaneDO extends DurableObject {
2386
2571
  granted_by, granted_at, revoked_at)
2387
2572
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`, row.connectionId, row.tenantId, row.vertical, row.permission, row.scopeId, row.expiresAt, row.grantedBy, row.grantedAt);
2388
2573
  }
2389
- // -- the schedule switch's record (#1674) — `system-switch-record.ts` is the whole rule ---
2390
- /** OFF's write, after the scope's switch held. */
2391
- recordSystemSwitchedOff(row) {
2392
- recordSystemSwitchedOff(this.kernelSql, row);
2574
+ // -- the kill switches' record (#1674, #2029) — `system-switch-record.ts` is the whole rule ---
2575
+ /** OFF's write, before the scope's switch moves (#1823). Answers the row as it was. */
2576
+ recordSwitchedOff(row) {
2577
+ return this.writeSwitchIntent(row, recordSwitchedOff);
2393
2578
  }
2394
- /** ON's write, before the scope moves. Answers the row as it was, for a failed ON to restore. */
2395
- recordSystemSwitchedOn(row) {
2396
- return recordSystemSwitchedOn(this.kernelSql, row);
2579
+ /** ON's write, before the scope moves. Answers the row as it was, for a call that held nothing to restore. */
2580
+ recordSwitchedOn(row) {
2581
+ return this.writeSwitchIntent(row, recordSwitchedOn);
2397
2582
  }
2398
- /** Put a row back as `recordSystemSwitchedOn` found it — the ON that followed it failed. */
2399
- restoreSystemSwitchRecord(key, prior) {
2400
- restoreSystemSwitchRecord(this.kernelSql, key, prior);
2583
+ /**
2584
+ * #2045 (Codex r3): a switch call's record write is also its write-ahead intent, in ONE
2585
+ * transaction: the record, the subject's owed mark under this call's id, and the scope's reconcile
2586
+ * receipt cleared, so the sweep reconciles it and its re-assert settles the mark. Nothing that
2587
+ * happens to the move after this (a throw, a lost answer, a failed clear) can leave the scope
2588
+ * apart from its record with no mark to repair it. A write a newer call's row refuses writes
2589
+ * nothing at all.
2590
+ */
2591
+ writeSwitchIntent(row, write) {
2592
+ return this.ctx.storage.transactionSync(() => {
2593
+ const prior = write(this.kernelSql, row);
2594
+ if (recordWriteSuperseded(prior, row))
2595
+ return prior;
2596
+ markSwitchOwed(this.kernelSql, row.kind, row.tenantId, row.scopeId, row.key, row.operationId);
2597
+ this.sql.exec('UPDATE scopes SET provisioned_version_id = NULL WHERE scope_id = ?', row.scopeId);
2598
+ return prior;
2599
+ });
2600
+ }
2601
+ /**
2602
+ * Put a row back as the switch call's record write found it — the move that followed it held
2603
+ * nothing. #2045: with `clearOwed`, the call's own owed mark goes in the same transaction. The
2604
+ * row put back carries the PRIOR call's operation id, which is the fence every later re-assert
2605
+ * confirms under, and a mark newer than that fence is never cleared by one; so a mark left behind
2606
+ * by a separate clear that failed would keep the scope un-receipted on every pass.
2607
+ */
2608
+ restoreSwitchRecord(key, prior, clearOwed = false) {
2609
+ this.ctx.storage.transactionSync(() => {
2610
+ restoreSwitchRecord(this.kernelSql, key, prior);
2611
+ if (clearOwed)
2612
+ clearSwitchOwed(this.kernelSql, key.kind, key.tenantId, key.scopeId, key.key, key.operationId);
2613
+ });
2614
+ }
2615
+ /**
2616
+ * #1823 (#2030 for a peer): which of these subjects the tenant holds a live TENANT-level grant
2617
+ * for — what the switch passes the scope as `tenantHeld`, so a subject whose only authority
2618
+ * there is a tenant tuple is still switchable. Read here, where the tenant tuples live.
2619
+ */
2620
+ tenantHeldOf(kind, tenantId, keys, now) {
2621
+ return tenantHeldOf(this.kernelSql, kind, tenantId, keys, now);
2401
2622
  }
2402
2623
  /** The fleet read. */
2403
2624
  listSystemSwitches(filter) {
2404
2625
  return listSystemSwitchRecords(this.kernelSql, filter);
2405
2626
  }
2406
- /** One scope's recorded positions, by module — the status read's `recorded` join. */
2407
- systemSwitchRecordsOf(tenantId, scopeId) {
2408
- return [...systemSwitchRecordsOf(this.kernelSql, tenantId, scopeId)];
2627
+ /** One scope's recorded positions of one kind, by subject — the status read's `recorded` join. */
2628
+ switchRecordsOf(kind, tenantId, scopeId) {
2629
+ return [...switchRecordsOf(this.kernelSql, kind, tenantId, scopeId)];
2630
+ }
2631
+ /** #2045: each record row's position and fence, by subject, read together — `switchRecordStatesOf`. */
2632
+ switchRecordStatesOf(kind, tenantId, scopeId) {
2633
+ return [...switchRecordStatesOf(this.kernelSql, kind, tenantId, scopeId)];
2634
+ }
2635
+ /** #2045: the subjects of one kind owed a re-assert on one scope. */
2636
+ switchesOwedOf(kind, tenantId, scopeId) {
2637
+ return [...switchesOwedOf(this.kernelSql, kind, tenantId, scopeId)];
2409
2638
  }
2410
- /** The modules a re-assert switches back off on one scope. */
2411
- switchedOffModulesOf(tenantId, scopeId) {
2412
- return switchedOffModulesOf(this.kernelSql, tenantId, scopeId);
2639
+ /** #2045: the scope confirmed a move of this subject under `fence` — `clearSwitchOwed`. */
2640
+ clearSwitchOwed(kind, tenantId, scopeId, key, fence) {
2641
+ clearSwitchOwed(this.kernelSql, kind, tenantId, scopeId, key, fence);
2413
2642
  }
2414
2643
  /** The tenant's LIVE connection grants (#592) — the provision/reconcile gather read. */
2415
2644
  listConnectionGrants(tenantId) {
@@ -2473,7 +2702,7 @@ export class ControlPlaneDO extends DurableObject {
2473
2702
  if (existing) {
2474
2703
  if (existing.principal_id === principal)
2475
2704
  return false;
2476
- throw new Error(`identity ${provider}:${externalId} in tenant ${tenantId} is already bound to ${existing.principal_id}`);
2705
+ throw substratError('conflict', `identity ${provider}:${externalId} in tenant ${tenantId} is already bound to ${existing.principal_id}`);
2477
2706
  }
2478
2707
  this.sql.exec(`INSERT INTO _substrat_identities
2479
2708
  (provider, external_id, principal_id, tenant_id, scope_id, created_at)
@@ -2733,17 +2962,28 @@ export class ControlPlaneDO extends DurableObject {
2733
2962
  where.push(`(${drifted} OR s.status NOT IN ('active', 'archived') OR t.status <> 'active')`);
2734
2963
  params.push(...driftParams);
2735
2964
  }
2736
- params.push(...driftParams, filter.limit ?? 1000);
2965
+ if (filter.unrecorded)
2966
+ where.push('COALESCE(r.tenant_recorded, 0) <> 1');
2967
+ // The unrecorded walk rotates on the ask, not the answer: never asked first, then the one asked
2968
+ // longest ago, so a scope that answers 0, or never answers at all, cannot starve the rest of a
2969
+ // bounded pass.
2970
+ const order = filter.unrecorded
2971
+ ? 'a.asked_at IS NOT NULL, a.asked_at, s.scope_id'
2972
+ : `CASE WHEN ${drifted} THEN 0 ELSE 1 END, s.scope_id`;
2973
+ if (!filter.unrecorded)
2974
+ params.push(...driftParams);
2975
+ params.push(filter.limit ?? 1000);
2737
2976
  return this.sql
2738
2977
  .exec(`SELECT s.tenant_id, s.scope_id, s.status AS scope_status, t.status AS tenant_status, r.delivered,
2739
2978
  ${epoch} AS epoch, COALESCE(rs.revision, 0) AS scope_rev, COALESCE(rt.revision, 0) AS tenant_rev
2740
2979
  FROM scopes s
2741
2980
  JOIN tenants t ON t.tenant_id = s.tenant_id
2742
2981
  LEFT JOIN scope_lifecycle_receipts r ON r.scope_id = s.scope_id
2982
+ LEFT JOIN scope_tenant_asks a ON a.scope_id = s.scope_id
2743
2983
  LEFT JOIN lifecycle_revisions rs ON rs.subject = 'scope:' || s.scope_id
2744
2984
  LEFT JOIN lifecycle_revisions rt ON rt.subject = 'tenant:' || s.tenant_id
2745
2985
  WHERE ${where.join(' AND ')}
2746
- ORDER BY CASE WHEN ${drifted} THEN 0 ELSE 1 END, s.scope_id
2986
+ ORDER BY ${order}
2747
2987
  LIMIT ?`, ...params)
2748
2988
  .toArray();
2749
2989
  }
@@ -2774,9 +3014,17 @@ export class ControlPlaneDO extends DurableObject {
2774
3014
  bumpLifecycleRevision(subject) {
2775
3015
  this.sql.exec('INSERT INTO lifecycle_revisions (subject, revision) VALUES (?, 1) ON CONFLICT (subject) DO UPDATE SET revision = revision + 1', subject);
2776
3016
  }
2777
- /** #1713: record what a scope's deployment acknowledged holding (`lifecycleReceipt`). */
2778
- recordLifecycleReceipt(scopeId, delivered, at) {
2779
- this.sql.exec('INSERT OR REPLACE INTO scope_lifecycle_receipts (scope_id, delivered, at) VALUES (?, ?, ?)', scopeId, delivered, at);
3017
+ /** #2016: note that the heal asked these scopes for their tenant record at `at`, before it asks. */
3018
+ recordTenantAsks(scopeIds, at) {
3019
+ for (const scopeId of scopeIds) {
3020
+ this.sql.exec(`INSERT OR REPLACE INTO scope_tenant_asks (scope_id, asked_at) SELECT ?, ? WHERE ${STILL_DELIVERED}`, scopeId, at, scopeId);
3021
+ }
3022
+ }
3023
+ /** #1713: record what a scope's deployment acknowledged holding (`lifecycleReceipt`), and #2016:
3024
+ * whether it answered that the scope holds a record of its tenant. */
3025
+ recordLifecycleReceipt(scopeId, delivered, at, tenantRecorded = false) {
3026
+ this.sql.exec(`INSERT OR REPLACE INTO scope_lifecycle_receipts (scope_id, delivered, at, tenant_recorded)
3027
+ SELECT ?, ?, ?, ? WHERE ${STILL_DELIVERED}`, scopeId, delivered, at, tenantRecorded ? 1 : 0, scopeId);
2780
3028
  }
2781
3029
  recordOpsFailure(row) {
2782
3030
  this.sql.exec(`INSERT INTO _substrat_ops_failures