@substrat-run/adapter-cloudflare 0.163.0 → 0.165.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.
@@ -3,7 +3,7 @@ import { membershipFencesBackfillSql, MEMBERSHIP_FENCES_DDL, MEMBERSHIP_FENCES_T
3
3
  import { replyOf } from './do-reply.js';
4
4
  import { switchSqlOver } from './scope-do.js';
5
5
  import { blankSqlComments, boundShapesColumn, executableSqlStatements, installsReconciledOf, PROVISIONED_VERSIONS_SQL, provisionedVersionsParams, } from '@substrat-run/kernel';
6
- import { COPY_BACKFILL_SCOPE_SQL, COPY_BACKFILL_SQL, COPY_BACKFILL_SUPERSEDE_SQL, COPY_CLAIM_SQL, COPY_EXPIRED_SQL, COPY_MOVE_RENEW_SQL, copyMoveRenewParams, PENDING_COPY_KEEP_ALIVE_SQL, pendingCopyKeepAliveParams, copyBackfillParams, copyBackfillRefusal, SUBJECT_ERASURE_CLAIM_SQL, subjectErasureClaimParams, COPY_MOVE_CONFIRM_SQL, COPY_MOVE_LIVE_PREDICATE, SCOPE_COPY_LEASE_MS, SERVES_NOW_CONDITION, SERVES_NOW_PREDICATE, servesNowParams, SCOPE_SCRIPT_COPY_COLUMNS, copyMoveConfirmParams, copyMoveLiveParams, } from '@substrat-run/kernel';
6
+ import { COPY_BACKFILL_SCOPE_SQL, COPY_BACKFILL_SQL, COPY_BACKFILL_SUPERSEDE_SQL, COPY_CLAIM_SQL, COPY_EXPIRED_SQL, COPY_MOVE_RENEW_SQL, copyMoveRenewParams, PENDING_COPY_KEEP_ALIVE_SQL, pendingCopyKeepAliveParams, copyBackfillParams, copyBackfillRefusal, SUBJECT_ERASURE_CLAIM_SQL, subjectErasureClaimParams, COPY_MOVE_CONFIRM_SQL, COPY_MOVE_LIVE_PREDICATE, SCOPE_COPY_LEASE_MS, SERVES_NOW_CONDITION, SERVES_NOW_PREDICATE, servesNowParams, servesAsFrom, SCOPE_SCRIPT_COPY_COLUMNS, copyMoveConfirmParams, copyMoveLiveParams, } from '@substrat-run/kernel';
7
7
  import { autoAdmittedProdRefusal, listedPromoteRefusal, opsFailureReferenceWhere } from '@substrat-run/kernel';
8
8
  import { doBuiltColumnsOf, doRedactionSql } from './sql.js';
9
9
  import { assertReplayableDump, AUTO_ADMISSION_NOTE, instant, opsFailureFingerprint, ROUTE_SCOPE_HOSTNAMES_MAX, SCOPE_GATE_REASONS, substratError } from '@substrat-run/contracts';
@@ -842,6 +842,25 @@ export const REPLIED_METHODS = [
842
842
  'deleteHostname',
843
843
  'grantEntitlement',
844
844
  'revokeEntitlement',
845
+ // #2479: these names are new even where the bare method above already exists. A DO from
846
+ // before the audit unit must refuse the name, not drop a trailing audit argument and commit.
847
+ 'putTenantStoreAndAudit',
848
+ 'putBlobStoreAndAudit',
849
+ 'recordStoreDriverReleaseAndAudit',
850
+ 'beginStoreDriverRolloutAndAudit',
851
+ 'finishStoreDriverRolloutAndAudit',
852
+ 'importDumpAndAudit',
853
+ 'registerIdentityPoolAndAudit',
854
+ 'insertConnectionAndAudit',
855
+ 'updateConnectionSecretAndAudit',
856
+ 'revokeConnectionAndAudit',
857
+ 'linkIdentityAndAudit',
858
+ 'unlinkIdentityAndAudit',
859
+ 'markAccessLogDrainedAndAudit',
860
+ 'pruneAccessLogAndAudit',
861
+ 'writeImpersonationAndAudit',
862
+ 'endImpersonationAndAudit',
863
+ 'setIssueStatusAndAudit',
845
864
  ];
846
865
  export class ControlPlaneDO extends DurableObject {
847
866
  sql;
@@ -1287,7 +1306,7 @@ export class ControlPlaneDO extends DurableObject {
1287
1306
  * are filled in afterwards, inside the same transaction. A refusal anywhere in it leaves the
1288
1307
  * directory as it was.
1289
1308
  */
1290
- async importDump(tables) {
1309
+ async importDump(tables, audit) {
1291
1310
  // Same untrusted dump, same two holes as the scope path (#1143): names reaching SQL as
1292
1311
  // identifiers, and a `ddl` that is never executed here but is still held to one CREATE
1293
1312
  // TABLE. Pure input validation, so it runs before the first DROP: a refused dump touches
@@ -1326,6 +1345,10 @@ export class ControlPlaneDO extends DurableObject {
1326
1345
  // A dump never carries one (`exportDump`); a hand-made dump that does is outranked too.
1327
1346
  // Every deployment then takes this history's deliveries, whatever its counters say.
1328
1347
  this.sql.exec("INSERT OR REPLACE INTO lifecycle_revisions (subject, revision) VALUES ('directory:epoch', ?)", Math.max(epochBefore + 1, this.lifecycleEpoch() + 1, Date.now()));
1348
+ // Append the restore's own row after the dump rows have landed in the rebuilt
1349
+ // admin table, inside this same import transaction.
1350
+ if (audit)
1351
+ this.recordAdmin(audit);
1329
1352
  });
1330
1353
  // A restore is a new #1764 backfill episode. It replaced the data the old failure count and
1331
1354
  // backoff were about, so neither may delay or silence it: the count is cleared and the next
@@ -1494,21 +1517,26 @@ export class ControlPlaneDO extends DurableObject {
1494
1517
  * callers converge on one canonical row and the loser can tear its orphan mint down.
1495
1518
  * Fails closed on an unknown/non-active tenant, exactly as provisionScope does.
1496
1519
  */
1497
- putTenantStore(row) {
1498
- const tenantRow = this.sql
1499
- .exec('SELECT status FROM tenants WHERE tenant_id = ?', row.tenantId)
1500
- .toArray()[0];
1501
- if (!tenantRow) {
1502
- throw substratError('not_found', `cannot provision tenant store under unknown tenant: ${row.tenantId}`);
1503
- }
1504
- if (tenantRow.status !== 'active') {
1505
- throw substratError('conflict', `cannot provision tenant store under non-active tenant (status: ${tenantRow.status}): ${row.tenantId}`);
1506
- }
1507
- this.sql.exec(`INSERT OR IGNORE INTO tenant_stores (tenant_id, vertical, binding, kind, ref, created_at)
1508
- VALUES (?, ?, ?, ?, ?, ?)`, row.tenantId, row.vertical, row.binding, row.kind, row.ref, row.createdAt);
1509
- const stored = this.getTenantStore(row.tenantId, row.vertical, row.binding);
1510
- if (!stored)
1511
- throw new Error(`tenant store write did not land for tenant ${row.tenantId}`);
1520
+ putTenantStore(row, audit) {
1521
+ let stored;
1522
+ this.writeAndAudit(() => {
1523
+ const tenantRow = this.sql
1524
+ .exec('SELECT status FROM tenants WHERE tenant_id = ?', row.tenantId)
1525
+ .toArray()[0];
1526
+ if (!tenantRow) {
1527
+ throw substratError('not_found', `cannot provision tenant store under unknown tenant: ${row.tenantId}`);
1528
+ }
1529
+ if (tenantRow.status !== 'active') {
1530
+ throw substratError('conflict', `cannot provision tenant store under non-active tenant (status: ${tenantRow.status}): ${row.tenantId}`);
1531
+ }
1532
+ const inserted = this.sql.exec(`INSERT OR IGNORE INTO tenant_stores (tenant_id, vertical, binding, kind, ref, created_at)
1533
+ VALUES (?, ?, ?, ?, ?, ?)`, row.tenantId, row.vertical, row.binding, row.kind, row.ref, row.createdAt).rowsWritten > 0;
1534
+ const found = this.getTenantStore(row.tenantId, row.vertical, row.binding);
1535
+ if (!found)
1536
+ throw new Error(`tenant store write did not land for tenant ${row.tenantId}`);
1537
+ stored = found;
1538
+ return inserted;
1539
+ }, audit);
1512
1540
  return stored;
1513
1541
  }
1514
1542
  listTenantStores(filter) {
@@ -1538,21 +1566,26 @@ export class ControlPlaneDO extends DurableObject {
1538
1566
  }
1539
1567
  /** Record a minted blob store — FIRST WRITER WINS, same race protocol as
1540
1568
  * putTenantStore: INSERT OR IGNORE then read back; the loser tears its orphan down. */
1541
- putBlobStore(row) {
1542
- const tenantRow = this.sql
1543
- .exec('SELECT status FROM tenants WHERE tenant_id = ?', row.tenantId)
1544
- .toArray()[0];
1545
- if (!tenantRow) {
1546
- throw substratError('not_found', `cannot provision blob store under unknown tenant: ${row.tenantId}`);
1547
- }
1548
- if (tenantRow.status !== 'active') {
1549
- throw substratError('conflict', `cannot provision blob store under non-active tenant (status: ${tenantRow.status}): ${row.tenantId}`);
1550
- }
1551
- this.sql.exec(`INSERT OR IGNORE INTO blob_stores (tenant_id, vertical, binding, kind, ref, created_at)
1552
- VALUES (?, ?, ?, ?, ?, ?)`, row.tenantId, row.vertical, row.binding, row.kind, row.ref, row.createdAt);
1553
- const stored = this.getBlobStore(row.tenantId, row.vertical, row.binding);
1554
- if (!stored)
1555
- throw new Error(`blob store write did not land for tenant ${row.tenantId}`);
1569
+ putBlobStore(row, audit) {
1570
+ let stored;
1571
+ this.writeAndAudit(() => {
1572
+ const tenantRow = this.sql
1573
+ .exec('SELECT status FROM tenants WHERE tenant_id = ?', row.tenantId)
1574
+ .toArray()[0];
1575
+ if (!tenantRow) {
1576
+ throw substratError('not_found', `cannot provision blob store under unknown tenant: ${row.tenantId}`);
1577
+ }
1578
+ if (tenantRow.status !== 'active') {
1579
+ throw substratError('conflict', `cannot provision blob store under non-active tenant (status: ${tenantRow.status}): ${row.tenantId}`);
1580
+ }
1581
+ const inserted = this.sql.exec(`INSERT OR IGNORE INTO blob_stores (tenant_id, vertical, binding, kind, ref, created_at)
1582
+ VALUES (?, ?, ?, ?, ?, ?)`, row.tenantId, row.vertical, row.binding, row.kind, row.ref, row.createdAt).rowsWritten > 0;
1583
+ const found = this.getBlobStore(row.tenantId, row.vertical, row.binding);
1584
+ if (!found)
1585
+ throw new Error(`blob store write did not land for tenant ${row.tenantId}`);
1586
+ stored = found;
1587
+ return inserted;
1588
+ }, audit);
1556
1589
  return stored;
1557
1590
  }
1558
1591
  listBlobStores(filter) {
@@ -1575,8 +1608,13 @@ export class ControlPlaneDO extends DurableObject {
1575
1608
  // -- store-driver releases, rollouts and holds (D-63, #2300) ---------------------
1576
1609
  // Kernel code over the directory (store-driver-releases.ts), shared with the SQLite host. Each
1577
1610
  // call is one transaction, and the object runs one at a time, so a hold is taken atomically.
1578
- recordStoreDriverRelease(input) {
1579
- return this.ctx.storage.transactionSync(() => recordStoreDriverReleaseIn(this.kernelSql, input));
1611
+ recordStoreDriverRelease(input, audit) {
1612
+ let result;
1613
+ this.writeAndAudit(() => {
1614
+ result = recordStoreDriverReleaseIn(this.kernelSql, input);
1615
+ return result.created;
1616
+ }, audit ? () => ({ ...audit, after: result.record }) : undefined);
1617
+ return result;
1580
1618
  }
1581
1619
  getStoreDriverRelease(driver, sha256) {
1582
1620
  return storeDriverReleaseIn(this.kernelSql, driver, sha256);
@@ -1599,11 +1637,21 @@ export class ControlPlaneDO extends DurableObject {
1599
1637
  releaseStoreDriverHold(input) {
1600
1638
  releaseStoreDriverHoldIn(this.kernelSql, input);
1601
1639
  }
1602
- beginStoreDriverRollout(input) {
1603
- return this.ctx.storage.transactionSync(() => beginStoreDriverRolloutIn(this.kernelSql, { ...input, nowMs: Date.now() }));
1640
+ beginStoreDriverRollout(input, audit) {
1641
+ let row;
1642
+ this.writeAndAudit(() => {
1643
+ row = beginStoreDriverRolloutIn(this.kernelSql, { ...input, nowMs: Date.now() });
1644
+ return true;
1645
+ }, audit ? () => ({ ...audit, after: row }) : undefined);
1646
+ return row;
1604
1647
  }
1605
- finishStoreDriverRollout(input) {
1606
- return this.ctx.storage.transactionSync(() => finishStoreDriverRolloutIn(this.kernelSql, { ...input, nowMs: Date.now() }));
1648
+ finishStoreDriverRollout(input, audit) {
1649
+ let row;
1650
+ this.writeAndAudit(() => {
1651
+ row = finishStoreDriverRolloutIn(this.kernelSql, { ...input, nowMs: Date.now() });
1652
+ return true;
1653
+ }, audit ? () => ({ ...audit, action: row.action === 'rollback' ? 'rollBackStoreDriver' : 'rollOutStoreDriver', after: row }) : undefined);
1654
+ return row;
1607
1655
  }
1608
1656
  storeDriverRebuild(driver) {
1609
1657
  return storeDriverRebuildIn(this.kernelSql, driver);
@@ -2552,11 +2600,13 @@ export class ControlPlaneDO extends DurableObject {
2552
2600
  }
2553
2601
  /**
2554
2602
  * Record what the serving script now runs — written only after a successful in-place upload, so
2555
- * a failed serve leaves the trailing state visible. #2528: `expected` is the serving ref and
2556
- * version the caller's #2178 gate judged the move from (nulls: nothing served); the write lands
2557
- * only if the row still names both (`SERVES_NOW_CONDITION`), and `audit`'s `before` is the
2558
- * serving state this unit read. False when either moved. Optional and trailing, so a caller from
2559
- * before it still writes unconditionally.
2603
+ * a failed serve leaves the trailing state visible. #2528: `expected` is the serving state the
2604
+ * caller's #2178 gate judged the move from (nulls: nothing served); the write lands only if the
2605
+ * row still holds it (`servesAsFrom`), and `audit`'s `before` is the serving state this unit read.
2606
+ * False when it moved. #2577 added the class set and tag to `expected`, since a serve and a
2607
+ * re-serve can bring the ref and version back over a grown class set. Each is optional, so a
2608
+ * coordinator from before still sends the ref and version alone and gets the CAS it always had;
2609
+ * `expected` is trailing, so one from before #2528 still writes unconditionally.
2560
2610
  */
2561
2611
  setVerticalServing(slug, s, audit, expected) {
2562
2612
  let before = null;
@@ -2566,11 +2616,12 @@ export class ControlPlaneDO extends DurableObject {
2566
2616
  if (!r)
2567
2617
  throw substratError('not_found', `unknown vertical '${slug}'`);
2568
2618
  before = r.serving_ref ? { ref: r.serving_ref, versionId: r.serving_version_id } : null;
2569
- const moved = this.sql.exec(`UPDATE verticals SET serving_ref = ?, serving_version_id = ?,
2570
- serving_do_classes = ?, serving_migration_tag = ?
2571
- WHERE ${expected === undefined ? 'slug = ?' : SERVES_NOW_CONDITION}`, s.ref, s.versionId, s.doClassesJson, s.migrationTag, ...(expected === undefined ? [slug] : [slug, expected.ref, expected.versionId])).rowsWritten > 0;
2572
- raced = !moved;
2573
- return moved;
2619
+ raced = expected !== undefined && !servesAsFrom(r, expected);
2620
+ if (raced)
2621
+ return false;
2622
+ this.sql.exec(`UPDATE verticals SET serving_ref = ?, serving_version_id = ?,
2623
+ serving_do_classes = ?, serving_migration_tag = ? WHERE slug = ?`, s.ref, s.versionId, s.doClassesJson, s.migrationTag, slug);
2624
+ return true;
2574
2625
  }, audit && (() => ({ ...audit, before })));
2575
2626
  return !raced;
2576
2627
  }
@@ -2727,6 +2778,62 @@ export class ControlPlaneDO extends DurableObject {
2727
2778
  return changed;
2728
2779
  });
2729
2780
  }
2781
+ /**
2782
+ * The coordinator calls these new RPC names for directory-only audited writes. The old names
2783
+ * remain callable by an older coordinator, which records its own row afterwards. An older DO
2784
+ * knows none of these names and refuses before the state write instead of dropping the row.
2785
+ */
2786
+ putTenantStoreAndAudit(...args) {
2787
+ return this.putTenantStore(...args);
2788
+ }
2789
+ putBlobStoreAndAudit(...args) {
2790
+ return this.putBlobStore(...args);
2791
+ }
2792
+ recordStoreDriverReleaseAndAudit(...args) {
2793
+ return this.recordStoreDriverRelease(...args);
2794
+ }
2795
+ beginStoreDriverRolloutAndAudit(...args) {
2796
+ return this.beginStoreDriverRollout(...args);
2797
+ }
2798
+ finishStoreDriverRolloutAndAudit(...args) {
2799
+ return this.finishStoreDriverRollout(...args);
2800
+ }
2801
+ importDumpAndAudit(...args) {
2802
+ return this.importDump(...args);
2803
+ }
2804
+ registerIdentityPoolAndAudit(...args) {
2805
+ return this.registerIdentityPool(...args);
2806
+ }
2807
+ insertConnectionAndAudit(...args) {
2808
+ return this.insertConnection(...args);
2809
+ }
2810
+ updateConnectionSecretAndAudit(...args) {
2811
+ return this.updateConnectionSecret(...args);
2812
+ }
2813
+ revokeConnectionAndAudit(...args) {
2814
+ return this.revokeConnection(...args);
2815
+ }
2816
+ linkIdentityAndAudit(...args) {
2817
+ return this.linkIdentity(...args);
2818
+ }
2819
+ unlinkIdentityAndAudit(...args) {
2820
+ return this.unlinkIdentity(...args);
2821
+ }
2822
+ markAccessLogDrainedAndAudit(...args) {
2823
+ return this.markAccessLogDrained(...args);
2824
+ }
2825
+ pruneAccessLogAndAudit(...args) {
2826
+ return this.pruneAccessLog(...args);
2827
+ }
2828
+ writeImpersonationAndAudit(...args) {
2829
+ return this.writeImpersonation(...args);
2830
+ }
2831
+ endImpersonationAndAudit(...args) {
2832
+ return this.endImpersonation(...args);
2833
+ }
2834
+ setIssueStatusAndAudit(...args) {
2835
+ return this.setIssueStatus(...args);
2836
+ }
2730
2837
  scopeErasureEpoch(tenantId, scopeId) {
2731
2838
  // NULL on every row from before the column (#1722): the same 0 every CAS compares against.
2732
2839
  const row = this.sql.exec('SELECT COALESCE(erasure_epoch, 0) AS erasure_epoch FROM scopes WHERE tenant_id = ? AND scope_id = ?', tenantId, scopeId)
@@ -3309,16 +3416,18 @@ export class ControlPlaneDO extends DurableObject {
3309
3416
  .toArray()[0];
3310
3417
  }
3311
3418
  /** Returns false when an identical registration already exists; throws on a conflicting one. */
3312
- registerIdentityPool(provider, topology, tenantId, createdAt) {
3313
- const existing = this.readPool(provider);
3314
- if (existing) {
3315
- if (existing.topology === topology && existing.tenant_id === tenantId)
3316
- return false;
3317
- throw substratError('conflict', `identity pool '${provider}' is already registered as ${existing.topology}` +
3318
- `${existing.tenant_id ? ` for tenant ${existing.tenant_id}` : ''}`);
3319
- }
3320
- this.sql.exec('INSERT INTO _substrat_identity_pools (provider, topology, tenant_id, created_at) VALUES (?, ?, ?, ?)', provider, topology, tenantId, createdAt);
3321
- return true;
3419
+ registerIdentityPool(provider, topology, tenantId, createdAt, audit) {
3420
+ return this.writeAndAudit(() => {
3421
+ const existing = this.readPool(provider);
3422
+ if (existing) {
3423
+ if (existing.topology === topology && existing.tenant_id === tenantId)
3424
+ return false;
3425
+ throw substratError('conflict', `identity pool '${provider}' is already registered as ${existing.topology}` +
3426
+ `${existing.tenant_id ? ` for tenant ${existing.tenant_id}` : ''}`);
3427
+ }
3428
+ this.sql.exec('INSERT INTO _substrat_identity_pools (provider, topology, tenant_id, created_at) VALUES (?, ?, ?, ?)', provider, topology, tenantId, createdAt);
3429
+ return true;
3430
+ }, audit);
3322
3431
  }
3323
3432
  identityTenants(provider, externalId) {
3324
3433
  return this.sql
@@ -3386,27 +3495,30 @@ export class ControlPlaneDO extends DurableObject {
3386
3495
  // The DO owns the rows; the coordinator owns the SecretBox, so ciphertext
3387
3496
  // arrives already sealed and leaves still sealed. This DO has never seen a
3388
3497
  // plaintext credential and cannot.
3389
- insertConnection(row) {
3390
- // Live-uniqueness is per ACCOUNT (COALESCEd like the index): a provider
3391
- // without account refs stays a singleton; a multi-namespace provider may
3392
- // hold one live connection per external account.
3393
- const live = this.sql
3394
- .exec(`SELECT id FROM _substrat_connections
3498
+ insertConnection(row, audit) {
3499
+ this.writeAndAudit(() => {
3500
+ // Live-uniqueness is per ACCOUNT (COALESCEd like the index): a provider
3501
+ // without account refs stays a singleton; a multi-namespace provider may
3502
+ // hold one live connection per external account.
3503
+ const live = this.sql
3504
+ .exec(`SELECT id FROM _substrat_connections
3395
3505
  WHERE tenant_id = ? AND vertical = ? AND provider = ?
3396
3506
  AND COALESCE(external_account_ref, '') = COALESCE(?, '') AND revoked_at IS NULL`, row.tenantId, row.vertical, row.provider, row.externalAccountRef)
3397
- .toArray()[0];
3398
- if (live) {
3399
- const account = row.externalAccountRef ? ` under account '${row.externalAccountRef}'` : '';
3400
- throw new Error(`tenant ${row.tenantId} already has a live '${row.provider}' connection ` +
3401
- `for vertical '${row.vertical}'${account} — revoke it before connecting another`);
3402
- }
3403
- this.sql.exec(`INSERT INTO _substrat_connections
3507
+ .toArray()[0];
3508
+ if (live) {
3509
+ const account = row.externalAccountRef ? ` under account '${row.externalAccountRef}'` : '';
3510
+ throw new Error(`tenant ${row.tenantId} already has a live '${row.provider}' connection ` +
3511
+ `for vertical '${row.vertical}'${account} — revoke it before connecting another`);
3512
+ }
3513
+ this.sql.exec(`INSERT INTO _substrat_connections
3404
3514
  (id, tenant_id, vertical, provider, label, status, external_account_ref,
3405
3515
  scopes, expires_at, last_ok_at, last_error, last_error_at,
3406
3516
  created_by, created_at, revoked_at)
3407
3517
  VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, NULL, NULL, NULL, ?, ?, NULL)`, row.id, row.tenantId, row.vertical, row.provider, row.label, row.externalAccountRef, row.scopes, row.expiresAt, row.createdBy, row.createdAt);
3408
- this.sql.exec(`INSERT INTO _substrat_connection_secrets (connection_id, key_id, ciphertext, updated_at)
3518
+ this.sql.exec(`INSERT INTO _substrat_connection_secrets (connection_id, key_id, ciphertext, updated_at)
3409
3519
  VALUES (?, ?, ?, ?)`, row.id, row.keyId, row.ciphertext, row.createdAt);
3520
+ return true;
3521
+ }, audit);
3410
3522
  }
3411
3523
  /** This connection's key rows — every one, current and retired (#687). */
3412
3524
  readConnectionKeys(connectionId) {
@@ -3475,12 +3587,15 @@ export class ControlPlaneDO extends DurableObject {
3475
3587
  }
3476
3588
  return rows[0];
3477
3589
  }
3478
- updateConnectionSecret(id, keyId, ciphertext, expiresAt, at) {
3479
- this.sql.exec(`UPDATE _substrat_connection_secrets
3590
+ updateConnectionSecret(id, keyId, ciphertext, expiresAt, at, audit) {
3591
+ this.writeAndAudit(() => {
3592
+ this.sql.exec(`UPDATE _substrat_connection_secrets
3480
3593
  SET key_id = ?, ciphertext = ?, updated_at = ? WHERE connection_id = ?`, keyId, ciphertext, at, id);
3481
- this.sql.exec(`UPDATE _substrat_connections
3594
+ this.sql.exec(`UPDATE _substrat_connections
3482
3595
  SET status = 'active', expires_at = ?, last_error = NULL, last_error_at = NULL
3483
3596
  WHERE id = ?`, expiresAt, id);
3597
+ return true;
3598
+ }, audit);
3484
3599
  }
3485
3600
  /**
3486
3601
  * Tombstone the connection and DELETE the sealed blob.
@@ -3489,21 +3604,23 @@ export class ControlPlaneDO extends DurableObject {
3489
3604
  * only be a liability. Returns false when already revoked, so the caller can
3490
3605
  * skip an audit row for a no-op.
3491
3606
  */
3492
- revokeConnection(id, at) {
3493
- const row = this.readConnection(id);
3494
- if (!row)
3495
- throw new Error(`connection not found: ${id}`);
3496
- if (row.revoked_at)
3497
- return false;
3498
- this.sql.exec(`UPDATE _substrat_connections SET status = 'revoked', revoked_at = ? WHERE id = ?`, at, id);
3499
- this.sql.exec('DELETE FROM _substrat_connection_secrets WHERE connection_id = ?', id);
3500
- // Connector state dies with the connection — its private bookkeeping.
3501
- this.sql.exec('DELETE FROM _substrat_connector_state WHERE connection_id = ?', id);
3502
- // #592: its grants tombstone (K-21 — evidence, not roster). Absent from the
3503
- // next gather, so no later provision/reconcile delivers them again.
3504
- this.sql.exec(`UPDATE _substrat_connection_grants SET revoked_at = ?
3607
+ revokeConnection(id, at, audit) {
3608
+ return this.writeAndAudit(() => {
3609
+ const row = this.readConnection(id);
3610
+ if (!row)
3611
+ throw new Error(`connection not found: ${id}`);
3612
+ if (row.revoked_at)
3613
+ return false;
3614
+ this.sql.exec(`UPDATE _substrat_connections SET status = 'revoked', revoked_at = ? WHERE id = ?`, at, id);
3615
+ this.sql.exec('DELETE FROM _substrat_connection_secrets WHERE connection_id = ?', id);
3616
+ // Connector state dies with the connection — its private bookkeeping.
3617
+ this.sql.exec('DELETE FROM _substrat_connector_state WHERE connection_id = ?', id);
3618
+ // #592: its grants tombstone (K-21 — evidence, not roster). Absent from the
3619
+ // next gather, so no later provision/reconcile delivers them again.
3620
+ this.sql.exec(`UPDATE _substrat_connection_grants SET revoked_at = ?
3505
3621
  WHERE connection_id = ? AND revoked_at IS NULL`, at, id);
3506
- return true;
3622
+ return true;
3623
+ }, audit);
3507
3624
  }
3508
3625
  // -- a vertical's mailed connect links (connections.md §3.5.4) ------------------
3509
3626
  // The kernel's statements (`connect-links.ts`), shared with the pure adapter. Each
@@ -3712,31 +3829,35 @@ export class ControlPlaneDO extends DurableObject {
3712
3829
  .filter((r) => !prefix || r.state_key.startsWith(prefix))
3713
3830
  .map((r) => ({ key: r.state_key, value: r.value }));
3714
3831
  }
3715
- linkIdentity(provider, externalId, principal, tenantId, scopeId, createdAt) {
3716
- const existing = this.sql
3717
- .exec(`SELECT principal_id FROM _substrat_identities
3832
+ linkIdentity(provider, externalId, principal, tenantId, scopeId, createdAt, audit) {
3833
+ return this.writeAndAudit(() => {
3834
+ const existing = this.sql
3835
+ .exec(`SELECT principal_id FROM _substrat_identities
3718
3836
  WHERE tenant_id = ? AND provider = ? AND external_id = ?`, tenantId, provider, externalId)
3719
- .toArray()[0];
3720
- if (existing) {
3721
- if (existing.principal_id === principal)
3722
- return false;
3723
- throw substratError('conflict', `identity ${provider}:${externalId} in tenant ${tenantId} is already bound to ${existing.principal_id}`);
3724
- }
3725
- this.sql.exec(`INSERT INTO _substrat_identities
3837
+ .toArray()[0];
3838
+ if (existing) {
3839
+ if (existing.principal_id === principal)
3840
+ return false;
3841
+ throw substratError('conflict', `identity ${provider}:${externalId} in tenant ${tenantId} is already bound to ${existing.principal_id}`);
3842
+ }
3843
+ this.sql.exec(`INSERT INTO _substrat_identities
3726
3844
  (provider, external_id, principal_id, tenant_id, scope_id, created_at)
3727
3845
  VALUES (?, ?, ?, ?, ?, ?)`, provider, externalId, principal, tenantId, scopeId, createdAt);
3728
- return true;
3846
+ return true;
3847
+ }, audit);
3729
3848
  }
3730
3849
  /** Remove a principal's identity link(s) in a tenant. Returns whether a row was
3731
3850
  * deleted so a no-op stays silent (idempotent). */
3732
- unlinkIdentity(tenantId, principal) {
3733
- const before = this.sql
3734
- .exec(`SELECT 1 FROM _substrat_identities WHERE tenant_id = ? AND principal_id = ?`, tenantId, principal)
3735
- .toArray();
3736
- if (before.length === 0)
3737
- return false;
3738
- this.sql.exec(`DELETE FROM _substrat_identities WHERE tenant_id = ? AND principal_id = ?`, tenantId, principal);
3739
- return true;
3851
+ unlinkIdentity(tenantId, principal, audit) {
3852
+ return this.writeAndAudit(() => {
3853
+ const before = this.sql
3854
+ .exec(`SELECT 1 FROM _substrat_identities WHERE tenant_id = ? AND principal_id = ?`, tenantId, principal)
3855
+ .toArray();
3856
+ if (before.length === 0)
3857
+ return false;
3858
+ this.sql.exec(`DELETE FROM _substrat_identities WHERE tenant_id = ? AND principal_id = ?`, tenantId, principal);
3859
+ return true;
3860
+ }, audit);
3740
3861
  }
3741
3862
  resolveIdentity(tenantId, provider, externalId) {
3742
3863
  const row = this.sql
@@ -3799,25 +3920,35 @@ export class ControlPlaneDO extends DurableObject {
3799
3920
  * Stamp `drained_at` on undrained rows up to `upToId`. The `IS NULL` guard makes a
3800
3921
  * retried shipment idempotent — it re-stamps nothing and returns 0 (K-24).
3801
3922
  */
3802
- markAccessLogDrained(upToId, drainedAt) {
3803
- const doomed = this.sql
3804
- .exec('SELECT id FROM _substrat_access_log WHERE id <= ? AND drained_at IS NULL', upToId)
3805
- .toArray().map((r) => r.id);
3806
- for (const id of doomed) {
3807
- this.sql.exec('UPDATE _substrat_access_log SET drained_at = ? WHERE id = ?', drainedAt, id);
3808
- }
3809
- return doomed.length;
3923
+ markAccessLogDrained(upToId, drainedAt, audit) {
3924
+ let drained = 0;
3925
+ this.writeAndAudit(() => {
3926
+ const doomed = this.sql
3927
+ .exec('SELECT id FROM _substrat_access_log WHERE id <= ? AND drained_at IS NULL', upToId)
3928
+ .toArray().map((r) => r.id);
3929
+ for (const id of doomed) {
3930
+ this.sql.exec('UPDATE _substrat_access_log SET drained_at = ? WHERE id = ?', drainedAt, id);
3931
+ }
3932
+ drained = doomed.length;
3933
+ return drained > 0;
3934
+ }, audit ? () => ({ ...audit, after: { drained, upToId, drainedAt } }) : undefined);
3935
+ return drained;
3810
3936
  }
3811
3937
  /** ONLY drained rows. Age alone is not a licence to delete evidence (K-24). */
3812
- pruneAccessLog(limit) {
3813
- assertRowLimit('limit', limit);
3814
- const doomed = this.sql
3815
- .exec('SELECT id FROM _substrat_access_log WHERE drained_at IS NOT NULL ORDER BY id LIMIT ?', limit)
3816
- .toArray().map((r) => r.id);
3817
- for (const id of doomed) {
3818
- this.sql.exec('DELETE FROM _substrat_access_log WHERE id = ?', id);
3819
- }
3820
- return doomed.length;
3938
+ pruneAccessLog(limit, audit) {
3939
+ let pruned = 0;
3940
+ this.writeAndAudit(() => {
3941
+ assertRowLimit('limit', limit);
3942
+ const doomed = this.sql
3943
+ .exec('SELECT id FROM _substrat_access_log WHERE drained_at IS NOT NULL ORDER BY id LIMIT ?', limit)
3944
+ .toArray().map((r) => r.id);
3945
+ for (const id of doomed) {
3946
+ this.sql.exec('DELETE FROM _substrat_access_log WHERE id = ?', id);
3947
+ }
3948
+ pruned = doomed.length;
3949
+ return pruned > 0;
3950
+ }, audit ? () => ({ ...audit, after: { pruned } }) : undefined);
3951
+ return pruned;
3821
3952
  }
3822
3953
  // -- per-subject keys (#37) -------------------------------------------------
3823
3954
  // The three row operations `createSubjectKeys` needs. The crypto is the kernel's; what
@@ -3870,9 +4001,9 @@ export class ControlPlaneDO extends DurableObject {
3870
4001
  // never reads these — the coordinator resolves the session and hands the DO the
3871
4002
  // two actors, which keeps the DO's invoke free of a directory round-trip.
3872
4003
  /** Insert a session the coordinator minted. Returns nothing; the caller holds it. */
3873
- writeImpersonation(values) {
3874
- this.sql.exec(`INSERT INTO _substrat_impersonations (${IMPERSONATION_COLUMNS})
3875
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ...values);
4004
+ writeImpersonation(values, audit) {
4005
+ this.writeAndAudit(() => this.sql.exec(`INSERT INTO _substrat_impersonations (${IMPERSONATION_COLUMNS})
4006
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ...values).rowsWritten > 0, audit);
3876
4007
  }
3877
4008
  /** One session by id, or undefined. The door's lookup and every invoke's re-read. */
3878
4009
  readImpersonation(id) {
@@ -3880,8 +4011,8 @@ export class ControlPlaneDO extends DurableObject {
3880
4011
  return this.sql.exec(q.sql, ...q.params).toArray()[0];
3881
4012
  }
3882
4013
  /** Close a session. Idempotent — a row already ended keeps its first `ended_at`. */
3883
- endImpersonation(id, endedAt) {
3884
- this.sql.exec('UPDATE _substrat_impersonations SET ended_at = ? WHERE id = ? AND ended_at IS NULL', endedAt, id);
4014
+ endImpersonation(id, endedAt, audit) {
4015
+ this.writeAndAudit(() => this.sql.exec('UPDATE _substrat_impersonations SET ended_at = ? WHERE id = ? AND ended_at IS NULL', endedAt, id).rowsWritten > 0, audit);
3885
4016
  }
3886
4017
  listImpersonations(filter, now) {
3887
4018
  const q = impersonationListQuery(filter, now);
@@ -4412,22 +4543,27 @@ export class ControlPlaneDO extends DurableObject {
4412
4543
  return rows.map((r) => issueOf(r));
4413
4544
  }
4414
4545
  /** The lifecycle write (#1233). Returns before/after for the caller's audit, or undefined. */
4415
- setIssueStatus(fingerprint, status, at) {
4416
- const rows = this.sql
4417
- .exec('SELECT * FROM _substrat_issues WHERE fingerprint = ?', fingerprint)
4418
- .toArray();
4419
- const existing = rows[0];
4420
- if (!existing)
4421
- return undefined;
4422
- const resolvedAt = status === 'resolved' ? at : null;
4423
- // "Resolved under X": freeze the newest version at the verdict, so a later
4424
- // regression can name the pair. A reopen or ignore clears it with the timestamp.
4425
- const resolvedVersion = status === 'resolved' ? existing.last_version : null;
4426
- this.sql.exec('UPDATE _substrat_issues SET status = ?, resolved_at = ?, resolved_version = ? WHERE fingerprint = ?', status, resolvedAt, resolvedVersion, fingerprint);
4427
- return {
4428
- before: issueOf(existing),
4429
- after: issueOf({ ...existing, status, resolved_at: resolvedAt, resolved_version: resolvedVersion }),
4430
- };
4546
+ setIssueStatus(fingerprint, status, at, audit) {
4547
+ let existing;
4548
+ let after;
4549
+ this.writeAndAudit(() => {
4550
+ const rows = this.sql
4551
+ .exec('SELECT * FROM _substrat_issues WHERE fingerprint = ?', fingerprint)
4552
+ .toArray();
4553
+ existing = rows[0];
4554
+ if (!existing)
4555
+ return false;
4556
+ const resolvedAt = status === 'resolved' ? at : null;
4557
+ // "Resolved under X": freeze the newest version at the verdict, so a later
4558
+ // regression can name the pair. A reopen or ignore clears it with the timestamp.
4559
+ const resolvedVersion = status === 'resolved' ? existing.last_version : null;
4560
+ this.sql.exec('UPDATE _substrat_issues SET status = ?, resolved_at = ?, resolved_version = ? WHERE fingerprint = ?', status, resolvedAt, resolvedVersion, fingerprint);
4561
+ after = { ...existing, status, resolved_at: resolvedAt, resolved_version: resolvedVersion };
4562
+ return true;
4563
+ }, audit ? () => ({ ...audit, vertical: after.last_vertical,
4564
+ before: { fingerprint, status: existing.status },
4565
+ after: { fingerprint, status: after.status } }) : undefined);
4566
+ return existing && after ? { before: issueOf(existing), after: issueOf(after) } : undefined;
4431
4567
  }
4432
4568
  /** #1054: idempotent on request_id — a replayed intent writes nothing. */
4433
4569
  recordModelUsage(row) {