@substrat-run/adapter-cloudflare 0.137.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, 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, 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,
@@ -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,
@@ -709,6 +713,19 @@ export const BACKFILL_FAILURES_KEY = 'versionMigrationsBackfillFailures';
709
713
  export function backfillBackoffMs(failures) {
710
714
  return Math.min(BACKFILL_PAUSE_MS * 2 ** failures, BACKFILL_BACKOFF_MAX_MS);
711
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
+ ];
712
729
  export class ControlPlaneDO extends DurableObject {
713
730
  sql;
714
731
  /** The directory's store as the kernel's SQL handle — the switch record's helpers (#1674). */
@@ -792,20 +809,30 @@ export class ControlPlaneDO extends DurableObject {
792
809
  * construction. A directory restore runs `buildDirectorySchema` inside its own transaction
793
810
  * instead (`importDump`).
794
811
  *
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
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
799
816
  * such a run its statements are held back from the loop below and run with the backfill.
800
817
  */
801
818
  applyDirectorySchema() {
802
- const switchRecordIsNew = !systemSwitchesTableExists(this.kernelSql);
803
- this.buildDirectorySchema({ holdSwitchRecord: switchRecordIsNew });
804
- 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) {
805
831
  this.ctx.storage.transactionSync(() => {
806
- for (const stmt of splitSqlStatements(SYSTEM_SWITCHES_DDL))
832
+ for (const stmt of splitSqlStatements(MEMBERSHIP_FENCES_DDL))
807
833
  this.sql.exec(stmt);
808
- this.sql.exec(SYSTEM_SWITCHES_BACKFILL_SQL);
834
+ // Stamped now, inside this unit (`membershipFencesBackfillSql` says why).
835
+ this.sql.exec(membershipFencesBackfillSql(new Date().toISOString()));
809
836
  });
810
837
  }
811
838
  }
@@ -813,12 +840,15 @@ export class ControlPlaneDO extends DurableObject {
813
840
  * The directory's tables as this code builds them, carrying whatever the directory already
814
841
  * holds forward to that shape: the construction's pass, and a restore's (#1898, #1912), which
815
842
  * 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.
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.
818
845
  */
819
- buildDirectorySchema({ holdSwitchRecord }) {
846
+ buildDirectorySchema({ holdSwitchRecords, holdFences = false }) {
847
+ const held = holdSwitchRecords.map(switchesTableOf);
820
848
  for (const stmt of DIRECTORY_DDL_PLAN.loop) {
821
- 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))
822
852
  continue;
823
853
  this.sql.exec(stmt);
824
854
  }
@@ -1135,12 +1165,13 @@ export class ControlPlaneDO extends DurableObject {
1135
1165
  // Every table from this code's DDL, onto an empty directory, so none of the pass's
1136
1166
  // rebuilds fires (each would reach `transactionSync`, which this async transaction cannot
1137
1167
  // hold): every table is created on the current shape.
1138
- this.buildDirectorySchema({ holdSwitchRecord: false });
1168
+ this.buildDirectorySchema({ holdSwitchRecords: [] });
1139
1169
  // Refuses a table this code does not build, adds unknown columns bare, loads the rows by
1140
1170
  // name, then the legacy scope and #1674 backfills, all inside this transaction.
1141
1171
  loadDirectoryDump(tables, {
1142
1172
  columnsOf: (name) => doBuiltColumnsOf(this.sql, name),
1143
1173
  exec: (sql) => this.sql.exec(sql),
1174
+ now: () => new Date().toISOString(),
1144
1175
  maxColumns: DO_SQL_LIMITS.columns,
1145
1176
  insert: (sql, rows) => {
1146
1177
  for (const row of rows)
@@ -1199,7 +1230,7 @@ export class ControlPlaneDO extends DurableObject {
1199
1230
  .exec('SELECT tenant_id FROM tenants WHERE slug = ?', slug)
1200
1231
  .toArray()[0];
1201
1232
  if (slugOwner) {
1202
- 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)`);
1203
1234
  }
1204
1235
  this.sql.exec(`INSERT INTO tenants (tenant_id, slug, name, status, created_at, provisioned_by_tenant)
1205
1236
  VALUES (?, ?, ?, 'active', ?, ?)`, id, slug, name, createdAt, provisionedByTenant);
@@ -1209,12 +1240,12 @@ export class ControlPlaneDO extends DurableObject {
1209
1240
  setTenantStatus(tenantId, status) {
1210
1241
  const before = this.readTenant(tenantId);
1211
1242
  if (!before)
1212
- throw new Error(`unknown tenant: ${tenantId}`);
1243
+ throw substratError('not_found', `unknown tenant: ${tenantId}`);
1213
1244
  // `reaped` is terminal and destroys data — unreachable here, only ever set by
1214
1245
  // reapTenant, so a plain status flip cannot forge a tombstone over live data
1215
1246
  // (§4.8, the tenant analogue of reapScope's archived-only gate).
1216
1247
  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)`);
1248
+ throw substratError('validation_failed', `tenant ${tenantId} cannot be set to 'reaped' via setTenantStatus — reap goes through reapTenant (control-plane.md §4.8)`);
1218
1249
  }
1219
1250
  // Stamp/clear deleting_at so the grace-window sweep can age tenants (§4.8):
1220
1251
  // entering `deleting` stamps, leaving it (an un-delete) clears — the same shape
@@ -1241,9 +1272,9 @@ export class ControlPlaneDO extends DurableObject {
1241
1272
  reapTenant(tenantId) {
1242
1273
  const before = this.readTenant(tenantId);
1243
1274
  if (!before)
1244
- throw new Error(`unknown tenant: ${tenantId}`);
1275
+ throw substratError('not_found', `unknown tenant: ${tenantId}`);
1245
1276
  if (before.status !== 'deleting') {
1246
- 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`);
1247
1278
  }
1248
1279
  // KEPT as the tombstone: the `tenants` row itself and `_substrat_admin_log` (the
1249
1280
  // compliance witness — never swept). Scope rows were already reaped individually.
@@ -1255,6 +1286,9 @@ export class ControlPlaneDO extends DurableObject {
1255
1286
  '_substrat_entitlements', // per-tenant SKU flags
1256
1287
  'orgs', // K-22 org records
1257
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
1258
1292
  ]) {
1259
1293
  this.sql.exec(`DELETE FROM ${table} WHERE tenant_id = ?`, tenantId);
1260
1294
  }
@@ -1270,7 +1304,7 @@ export class ControlPlaneDO extends DurableObject {
1270
1304
  setTenantName(tenantId, name) {
1271
1305
  const before = this.readTenant(tenantId);
1272
1306
  if (!before)
1273
- throw new Error(`unknown tenant: ${tenantId}`);
1307
+ throw substratError('not_found', `unknown tenant: ${tenantId}`);
1274
1308
  this.sql.exec('UPDATE tenants SET name = ? WHERE tenant_id = ?', name, tenantId);
1275
1309
  return before.name;
1276
1310
  }
@@ -1304,10 +1338,10 @@ export class ControlPlaneDO extends DurableObject {
1304
1338
  .exec('SELECT status FROM tenants WHERE tenant_id = ?', row.tenantId)
1305
1339
  .toArray()[0];
1306
1340
  if (!tenantRow) {
1307
- 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}`);
1308
1342
  }
1309
1343
  if (tenantRow.status !== 'active') {
1310
- 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}`);
1311
1345
  }
1312
1346
  this.sql.exec(`INSERT OR IGNORE INTO tenant_stores (tenant_id, vertical, binding, kind, ref, created_at)
1313
1347
  VALUES (?, ?, ?, ?, ?, ?)`, row.tenantId, row.vertical, row.binding, row.kind, row.ref, row.createdAt);
@@ -1348,10 +1382,10 @@ export class ControlPlaneDO extends DurableObject {
1348
1382
  .exec('SELECT status FROM tenants WHERE tenant_id = ?', row.tenantId)
1349
1383
  .toArray()[0];
1350
1384
  if (!tenantRow) {
1351
- 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}`);
1352
1386
  }
1353
1387
  if (tenantRow.status !== 'active') {
1354
- 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}`);
1355
1389
  }
1356
1390
  this.sql.exec(`INSERT OR IGNORE INTO blob_stores (tenant_id, vertical, binding, kind, ref, created_at)
1357
1391
  VALUES (?, ?, ?, ?, ?, ?)`, row.tenantId, row.vertical, row.binding, row.kind, row.ref, row.createdAt);
@@ -1388,10 +1422,10 @@ export class ControlPlaneDO extends DurableObject {
1388
1422
  .exec('SELECT status FROM tenants WHERE tenant_id = ?', tenantId)
1389
1423
  .toArray()[0];
1390
1424
  if (!tenantRow) {
1391
- throw new Error(`cannot provision scope under unknown tenant: ${tenantId}`);
1425
+ throw substratError('conflict', `cannot provision scope under unknown tenant: ${tenantId}`);
1392
1426
  }
1393
1427
  if (tenantRow.status !== 'active') {
1394
- 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}`);
1395
1429
  }
1396
1430
  // Idempotency is on the scope_id (§3.3: idempotent and journaled — safe to
1397
1431
  // re-run), so an existing scope short-circuits before the slug check:
@@ -1404,7 +1438,7 @@ export class ControlPlaneDO extends DurableObject {
1404
1438
  .exec("SELECT scope_id FROM scopes WHERE tenant_id = ? AND slug = ? AND status NOT IN ('archived', 'reaped')", tenantId, record.slug)
1405
1439
  .toArray()[0];
1406
1440
  if (slugOwner) {
1407
- 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} ` +
1408
1442
  `by ${slugOwner.scope_id} (slugs are unique within a tenant)`);
1409
1443
  }
1410
1444
  // A scope born while its vertical serves in place (#286) is born ON the serving
@@ -1486,6 +1520,21 @@ export class ControlPlaneDO extends DurableObject {
1486
1520
  return undefined;
1487
1521
  return row;
1488
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
+ }
1489
1538
  /**
1490
1539
  * The getScope gate (control-plane.md §4.1/§4.2): validate the scope belongs
1491
1540
  * to the tenant and both records are active, or throw the fail-closed reason.
@@ -1502,11 +1551,10 @@ export class ControlPlaneDO extends DurableObject {
1502
1551
  * The same gate, answering its refusal as DATA (#1718). An error thrown in a Durable
1503
1552
  * Object arrives at the coordinator flattened, its code gone, so a typed throw here
1504
1553
  * 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.
1554
+ * the coordinator throws the refusal itself, typed (#113: every refusal but a failed
1555
+ * migration carries its code). Null means access is allowed.
1507
1556
  */
1508
1557
  scopeAccessRefusal(tenantId, scopeId) {
1509
- const refuse = (message) => ({ code: null, message });
1510
1558
  const row = this.sql
1511
1559
  .exec('SELECT tenant_id, status FROM scopes WHERE scope_id = ?', scopeId)
1512
1560
  .toArray()[0];
@@ -1517,10 +1565,14 @@ export class ControlPlaneDO extends DurableObject {
1517
1565
  .exec('SELECT status FROM tenants WHERE tenant_id = ?', tenantId)
1518
1566
  .toArray()[0];
1519
1567
  if (!tenantRow) {
1520
- 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 };
1521
1569
  }
1522
1570
  if (tenantRow.status !== 'active') {
1523
- 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
+ };
1524
1576
  }
1525
1577
  if (row.status !== 'active') {
1526
1578
  // A scope stuck in provisioning because its migrations failed must say so.
@@ -1531,10 +1583,17 @@ export class ControlPlaneDO extends DurableObject {
1531
1583
  .exec('SELECT migration_failed_version, migration_error FROM scopes WHERE scope_id = ?', scopeId)
1532
1584
  .toArray()[0];
1533
1585
  if (failure?.migration_failed_version) {
1534
- return refuse(`migration failed for ${failure.migration_failed_version} — scope fails closed: ` +
1535
- `${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
+ };
1536
1591
  }
1537
- 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
+ };
1538
1597
  }
1539
1598
  return null;
1540
1599
  }
@@ -1569,7 +1628,7 @@ export class ControlPlaneDO extends DurableObject {
1569
1628
  if (!from.includes(row.status)) {
1570
1629
  return {
1571
1630
  ok: false,
1572
- code: null,
1631
+ code: 'conflict',
1573
1632
  message: `illegal scope transition for ${action}: ${row.status} → ${to} ` +
1574
1633
  `(allowed from: ${from.join('|')})`,
1575
1634
  };
@@ -1590,7 +1649,7 @@ export class ControlPlaneDO extends DurableObject {
1590
1649
  this.sql.exec('UPDATE scopes SET status = ? WHERE scope_id = ?', to, scopeId);
1591
1650
  }
1592
1651
  if (to === 'reaped') {
1593
- forgetSystemSwitchesOf(this.kernelSql, scopeId);
1652
+ forgetSwitchesOf(this.kernelSql, scopeId);
1594
1653
  this.forgetLifecycleDeliveries('scope_id = ?', scopeId);
1595
1654
  }
1596
1655
  this.bumpLifecycleRevision(`scope:${scopeId}`); // #1713, with the status it counts
@@ -1998,6 +2057,11 @@ export class ControlPlaneDO extends DurableObject {
1998
2057
  * for a repair that failed, which is the one outcome that must keep being retried.
1999
2058
  */
2000
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;
2001
2065
  this.sql.exec('UPDATE scopes SET provisioned_version_id = ? WHERE scope_id = ?', versionId, scopeId);
2002
2066
  }
2003
2067
  /**
@@ -2008,7 +2072,7 @@ export class ControlPlaneDO extends DurableObject {
2008
2072
  deleteScopeDirectory(scopeId) {
2009
2073
  this.ctx.storage.transactionSync(() => {
2010
2074
  this.sql.exec('DELETE FROM hostnames WHERE scope_id = ?', scopeId);
2011
- forgetSystemSwitchesOf(this.kernelSql, scopeId);
2075
+ forgetSwitchesOf(this.kernelSql, scopeId);
2012
2076
  this.forgetLifecycleDeliveries('scope_id = ?', scopeId);
2013
2077
  this.sql.exec('DELETE FROM scopes WHERE scope_id = ?', scopeId);
2014
2078
  });
@@ -2075,7 +2139,7 @@ export class ControlPlaneDO extends DurableObject {
2075
2139
  .exec('SELECT org_id FROM orgs WHERE tenant_id = ? AND slug = ?', tenantId, slug)
2076
2140
  .toArray()[0];
2077
2141
  if (slugOwner) {
2078
- 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)`);
2079
2143
  }
2080
2144
  this.sql.exec('INSERT INTO orgs (org_id, tenant_id, slug, name, created_at) VALUES (?, ?, ?, ?, ?)', orgId, tenantId, slug, name, createdAt);
2081
2145
  return true;
@@ -2085,13 +2149,69 @@ export class ControlPlaneDO extends DurableObject {
2085
2149
  .exec('SELECT * FROM orgs WHERE tenant_id = ? ORDER BY slug', tenantId)
2086
2150
  .toArray();
2087
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
+ };
2088
2158
  /**
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.
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`).
2092
2165
  */
2093
- revokeMember(tenantId, subject, object, at) {
2094
- 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;
2095
2215
  }
2096
2216
  /** Tombstone any tenant tuple by its exact (subject, relation, object). Returns
2097
2217
  * whether a live row changed, so a repeat revoke is a silent no-op. */
@@ -2248,7 +2368,7 @@ export class ControlPlaneDO extends DurableObject {
2248
2368
  if (existing) {
2249
2369
  if (existing.topology === topology && existing.tenant_id === tenantId)
2250
2370
  return false;
2251
- 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}` +
2252
2372
  `${existing.tenant_id ? ` for tenant ${existing.tenant_id}` : ''}`);
2253
2373
  }
2254
2374
  this.sql.exec('INSERT INTO _substrat_identity_pools (provider, topology, tenant_id, created_at) VALUES (?, ?, ?, ?)', provider, topology, tenantId, createdAt);
@@ -2451,38 +2571,74 @@ export class ControlPlaneDO extends DurableObject {
2451
2571
  granted_by, granted_at, revoked_at)
2452
2572
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`, row.connectionId, row.tenantId, row.vertical, row.permission, row.scopeId, row.expiresAt, row.grantedBy, row.grantedAt);
2453
2573
  }
2454
- // -- the schedule switch's record (#1674) — `system-switch-record.ts` is the whole rule ---
2574
+ // -- the kill switches' record (#1674, #2029) — `system-switch-record.ts` is the whole rule ---
2455
2575
  /** 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);
2576
+ recordSwitchedOff(row) {
2577
+ return this.writeSwitchIntent(row, recordSwitchedOff);
2458
2578
  }
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);
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);
2462
2582
  }
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);
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
+ });
2466
2614
  }
2467
2615
  /**
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.
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.
2471
2619
  */
2472
- tenantHeldSystemModules(tenantId, moduleIds, now) {
2473
- return moduleIds.filter((m) => tenantHoldsSystemGrant(this.kernelSql, tenantId, m, now));
2620
+ tenantHeldOf(kind, tenantId, keys, now) {
2621
+ return tenantHeldOf(this.kernelSql, kind, tenantId, keys, now);
2474
2622
  }
2475
2623
  /** The fleet read. */
2476
2624
  listSystemSwitches(filter) {
2477
2625
  return listSystemSwitchRecords(this.kernelSql, filter);
2478
2626
  }
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)];
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)];
2482
2638
  }
2483
- /** The modules a re-assert switches back off on one scope. */
2484
- switchedOffModulesOf(tenantId, scopeId) {
2485
- 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);
2486
2642
  }
2487
2643
  /** The tenant's LIVE connection grants (#592) — the provision/reconcile gather read. */
2488
2644
  listConnectionGrants(tenantId) {
@@ -2546,7 +2702,7 @@ export class ControlPlaneDO extends DurableObject {
2546
2702
  if (existing) {
2547
2703
  if (existing.principal_id === principal)
2548
2704
  return false;
2549
- 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}`);
2550
2706
  }
2551
2707
  this.sql.exec(`INSERT INTO _substrat_identities
2552
2708
  (provider, external_id, principal_id, tenant_id, scope_id, created_at)