@substrat-run/adapter-cloudflare 0.159.0 → 0.161.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,11 +1,12 @@
1
1
  import { DurableObject } from 'cloudflare:workers';
2
- import { membershipFencesBackfillSql, MEMBERSHIP_FENCES_DDL, MEMBERSHIP_FENCES_TABLE, membershipFencesTableExists, MEMBERSHIP_FENCE_SINCE_SQL, RAISE_MEMBERSHIP_FENCE_SQL, memberAddedAudit, orgChangeBound, reservedRoleRefusal, tenantCoverage, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, FINDINGS_DDL, SCOPE_STORAGE_DDL, forgetScopeStorage, listScopeStorageAttemptRows, listScopeStorageRows, listScopeStoreStorageRows, pruneScopeStorageRows, recordScopeStorageRows, CONNECT_LINKS_DDL, consumeConnectLinkRow, insertConnectLink as insertConnectLinkRow, listConnectLinks as listConnectLinkRows, readConnectLink as readConnectLinkRow, restoreConnectLinkRow, revokeConnectLinkRow, createFindingRule, findingOfOpsFailure, findingOfSweepRun, pruneFindings, listFindingRules, listFindings, observeFinding, revokeFindingRule, setFindingStatus, assertRowLimit, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, PEER_SWITCHES_DDL, PEER_BINDINGS_DDL, SWITCH_OWED_DDL, clearSwitchOwed, markSwitchOwed, scopeOwesSwitch, switchesOwedOf, SWITCH_KINDS, SYSTEM_SWITCHES_DDL, forgetSwitchesOf, listSystemSwitchRecords, recordSwitchedOff, recordSwitchedOn, recordWriteSuperseded, restoreSwitchRecord, scopesSwitchedOffFor, switchRecordStatesOf, switchRecordsOf, switchesBackfillSqlOf, switchesDdlOf, switchesTableExists, switchesTableOf, tenantHeldOf, VERSION_MIGRATIONS_DDL, VERTICAL_SCOPE_STORES_DDL, STORE_DRIVER_RELEASES_DDL, legacyStoreDriverConflict, legacyStoreDriverRenames, beginStoreDriverRolloutIn, finishStoreDriverRolloutIn, holdStoreDriverIn, advanceStoreDriverRebuildIn, claimStoreDriverRebuildIn, storeDriverRebuildIn, driverDeclarationsOf, recordStoreDriverReleaseIn, releaseStoreDriverHoldIn, storeDriverReleaseIn, storeDriverStateIn, storeDriverRolloutPendingIn, storeDriverRolloutWalkedIn, forgetVerticalScopeStores, indexVersionScopeStores, verticalScopeStoresOf, splitVersionMigrationsBatch, versionMigrationsOf, versionsAwaitSplit, versionsAwaitDigest, deriveMigrationDigestsBatch, deriveVersionMigrationDigest, writeVersionMigrations, MODEL_USAGE_RETENTION_DAYS, DO_SQL_LIMITS, ulid, isPrimaryScopeRow, samePeerVertical, peerVerticalSpellings, resolveVerticalInstanceFrom, resolvePeerInstanceFrom, LEGACY_SCOPE_ROWS_BACKFILL, loadDirectoryDump, redactSubjectDirectoryText, ISSUE_EXEMPLAR_OWNER_BACKFILL_SQL, issueExemplarOwner, SETTLE_INTENT_SQL, SETTLE_OUTCOME_SQL, settleOutcomeParamsOf, unknownOutcomeOf, ADMIN_LOG_INDEX_DDL, ADMIN_LOG_INDEXES_SQL, readAuditedOperations, } from '@substrat-run/kernel';
2
+ import { membershipFencesBackfillSql, MEMBERSHIP_FENCES_DDL, MEMBERSHIP_FENCES_TABLE, membershipFencesTableExists, MEMBERSHIP_FENCE_SINCE_SQL, RAISE_MEMBERSHIP_FENCE_SQL, memberAddedAudit, orgChangeBound, reservedRoleDefinitionRefusal, reservedRoleRefusal, TENANT_SEATED_ROLES_SQL, tenantCoverage, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, ISSUE_RETENTION_DAYS, telemetryRetentionStatements, FINDINGS_DDL, SCOPE_STORAGE_DDL, forgetScopeStorage, retireScopeStorage, listScopeStorageAttemptRows, listScopeStorageRows, listScopeAttachmentStorageRows, listScopeStoreStorageRows, pruneScopeStorageRows, recordScopeStorageRows, REQUEST_ROLLUP_DDL, listRequestDayRows, listRequestRollupDayRows, pruneRequestDayRows, recordRequestDayRows, METER_DAYS_DDL, VERTICAL_OWNERS_BACKFILL, VERTICAL_OWNERS_DDL, closeVerticalOwnerRow, listVerticalOwnerRows, openVerticalOwnerRow, verticalOwnerNow, listMeterDayRows, listMeterDayTakeRows, pruneMeterDayRows, recordMeterDayRows, CONNECT_LINKS_DDL, consumeConnectLinkRow, insertConnectLink as insertConnectLinkRow, listConnectLinks as listConnectLinkRows, readConnectLink as readConnectLinkRow, restoreConnectLinkRow, revokeConnectLinkRow, createFindingRule, findingOfOpsFailure, findingOfSweepRun, pruneFindings, listFindingRules, listFindings, observeFinding, revokeFindingRule, setFindingStatus, assertRowLimit, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, PEER_SWITCHES_DDL, PEER_BINDINGS_DDL, SWITCH_OWED_DDL, clearSwitchOwed, markSwitchOwed, scopeOwesSwitch, switchesOwedOf, SWITCH_KINDS, SYSTEM_SWITCHES_DDL, forgetSwitchesOf, listSystemSwitchRecords, recordSwitchedOff, recordSwitchedOn, recordWriteSuperseded, restoreSwitchRecord, scopesSwitchedOffFor, switchRecordStatesOf, switchRecordsOf, switchesBackfillSqlOf, switchesDdlOf, switchesTableExists, switchesTableOf, tenantHeldOf, VERSION_MIGRATIONS_DDL, VERTICAL_SCOPE_STORES_DDL, DEPLOYMENT_WIRE_AUTH_DDL, deploymentWireAuthOf, forgetVerticalWireAuth, writeDeploymentWireAuth, STORE_DRIVER_RELEASES_DDL, legacyStoreDriverConflict, legacyStoreDriverRenames, beginStoreDriverRolloutIn, finishStoreDriverRolloutIn, holdStoreDriverIn, advanceStoreDriverRebuildIn, claimStoreDriverRebuildIn, storeDriverRebuildIn, driverDeclarationsOf, recordStoreDriverReleaseIn, releaseStoreDriverHoldIn, storeDriverReleaseIn, storeDriverStateIn, storeDriverRolloutPendingIn, storeDriverRolloutWalkedIn, forgetVerticalScopeStores, indexVersionScopeStores, verticalScopeStoresOf, splitVersionMigrationsBatch, versionMigrationsOf, versionsAwaitSplit, versionsAwaitDigest, deriveMigrationDigestsBatch, deriveVersionMigrationDigest, writeVersionMigrations, MODEL_USAGE_RETENTION_DAYS, DO_SQL_LIMITS, ulid, isPrimaryScopeRow, samePeerVertical, peerVerticalSpellings, resolveVerticalInstanceFrom, resolvePeerInstanceFrom, LEGACY_SCOPE_ROWS_BACKFILL, loadDirectoryDump, redactSubjectDirectoryText, ISSUE_EXEMPLAR_OWNER_BACKFILL_SQL, issueExemplarOwner, SETTLE_INTENT_SQL, SETTLE_OUTCOME_SQL, settleOutcomeParamsOf, unknownOutcomeOf, ADMIN_LOG_INDEX_DDL, ADMIN_LOG_INDEXES_SQL, readAuditedOperations, } from '@substrat-run/kernel';
3
3
  import { replyOf } from './do-reply.js';
4
4
  import { switchSqlOver } from './scope-do.js';
5
5
  import { blankSqlComments, boundShapesColumn, executableSqlStatements, installsReconciledOf, PROVISIONED_VERSIONS_SQL, provisionedVersionsParams, } from '@substrat-run/kernel';
6
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';
7
+ import { opsFailureReferenceWhere } from '@substrat-run/kernel';
7
8
  import { doBuiltColumnsOf, doRedactionSql } from './sql.js';
8
- import { assertReplayableDump, opsFailureFingerprint, ROUTE_SCOPE_HOSTNAMES_MAX, SCOPE_GATE_REASONS, substratError } from '@substrat-run/contracts';
9
+ import { assertReplayableDump, AUTO_ADMISSION_NOTE, instant, opsFailureFingerprint, ROUTE_SCOPE_HOSTNAMES_MAX, SCOPE_GATE_REASONS, substratError } from '@substrat-run/contracts';
9
10
  /**
10
11
  * The router's per-request directory read (`readRoute`), lifted out so its plan can be asked for
11
12
  * (#2005): the scope's hostname set is a correlated subquery on the hot path every request takes,
@@ -315,6 +316,7 @@ const DIRECTORY_DDL = `
315
316
  );
316
317
  ${VERSION_MIGRATIONS_DDL}
317
318
  ${VERTICAL_SCOPE_STORES_DDL}
319
+ ${DEPLOYMENT_WIRE_AUTH_DDL}
318
320
  CREATE TABLE IF NOT EXISTS vertical_channels (
319
321
  vertical_slug TEXT NOT NULL,
320
322
  channel TEXT NOT NULL,
@@ -653,6 +655,12 @@ const DIRECTORY_DDL = `
653
655
  CREATE INDEX IF NOT EXISTS _substrat_model_usage_at ON _substrat_model_usage (at);
654
656
  -- #1524: the stored storage gauge — one row per (scope, UTC day), kernel-owned DDL.
655
657
  ${SCOPE_STORAGE_DDL}
658
+ -- #2501: the daily request rollup — one row per (UTC day, tenant, vertical, scope), kernel-owned DDL.
659
+ ${REQUEST_ROLLUP_DDL}
660
+ -- #2501: the daily meter snapshot — one row per (UTC day, tenant), kernel-owned DDL.
661
+ ${METER_DAYS_DDL}
662
+ -- #2501: who owned each vertical slug, period by period — kernel-owned DDL.
663
+ ${VERTICAL_OWNERS_DDL}
656
664
  CREATE INDEX IF NOT EXISTS scopes_tenant ON scopes (tenant_id, scope_id);
657
665
  -- #1713: the lifecycle each hosted scope's deployment last acknowledged, as
658
666
  -- "<scope status>/<tenant status>" (lifecycleReceipt). A CP-less deployment has no
@@ -799,6 +807,17 @@ export const REPLIED_METHODS = [
799
807
  // #2309: a fleet rebuild's lease write refuses with conflict or validation_failed.
800
808
  'claimStoreDriverRebuild',
801
809
  'advanceStoreDriverRebuild',
810
+ // #2528: the vertical writes that read their row's `before` and their guards in the unit that
811
+ // writes, refusing an unknown vertical or version with not_found and a failed guard with conflict.
812
+ 'updateVerticalListed',
813
+ 'updateVerticalPublishRequest',
814
+ 'updateVerticalInstallsBlocked',
815
+ 'updateVerticalTenantProvisioner',
816
+ 'updateVerticalEmailSender',
817
+ 'deleteVertical',
818
+ 'admitVersion',
819
+ 'rejectVersion',
820
+ 'setVerticalServing',
802
821
  ];
803
822
  export class ControlPlaneDO extends DurableObject {
804
823
  sql;
@@ -1177,6 +1196,8 @@ export class ControlPlaneDO extends DurableObject {
1177
1196
  this.addColumn('_substrat_entitlements', 'granted_by TEXT');
1178
1197
  for (const stmt of LEGACY_SCOPE_ROWS_BACKFILL)
1179
1198
  this.sql.exec(stmt);
1199
+ // #2501: an ownership period for every vertical registered before the history existed.
1200
+ this.sql.exec(VERTICAL_OWNERS_BACKFILL);
1180
1201
  // After the backfill: a UNIQUE index over NULL slugs would permit the
1181
1202
  // duplicates it exists to forbid (SQLite treats NULLs as distinct).
1182
1203
  //
@@ -1316,8 +1337,11 @@ export class ControlPlaneDO extends DurableObject {
1316
1337
  .toArray()[0];
1317
1338
  return row ? this.mapTenant(row) : undefined;
1318
1339
  }
1319
- /** Idempotent on the id; return the new tenant, or null if it already existed. */
1320
- createTenant(id, slug, name, createdAt, provisionedByTenant = null) {
1340
+ /**
1341
+ * Idempotent on the id; return the new tenant, or null if it already existed. `audit` is the
1342
+ * coordinator's row, written in the insert's unit with the tenant as its `after`.
1343
+ */
1344
+ createTenant(id, slug, name, createdAt, provisionedByTenant = null, audit) {
1321
1345
  if (this.readTenant(id))
1322
1346
  return null; // idempotent — nothing created
1323
1347
  // Checked explicitly rather than left to `INSERT OR IGNORE` + the
@@ -1327,15 +1351,17 @@ export class ControlPlaneDO extends DurableObject {
1327
1351
  const slugOwner = this.sql
1328
1352
  .exec('SELECT tenant_id FROM tenants WHERE slug = ?', slug)
1329
1353
  .toArray()[0];
1354
+ // #2556: the refusal never names the owner. Another tenant holds it, and the text reaches
1355
+ // whoever asked (a manager vertical's intent journal among them).
1330
1356
  if (slugOwner) {
1331
- throw substratError('conflict', `tenant slug '${slug}' already taken by ${slugOwner.tenant_id} (slugs are unique)`);
1357
+ throw substratError('conflict', `tenant slug '${slug}' is already taken (slugs are unique)`);
1332
1358
  }
1333
- this.sql.exec(`INSERT INTO tenants (tenant_id, slug, name, status, created_at, provisioned_by_tenant)
1334
- VALUES (?, ?, ?, 'active', ?, ?)`, id, slug, name, createdAt, provisionedByTenant);
1359
+ this.writeAndAudit(() => this.sql.exec(`INSERT INTO tenants (tenant_id, slug, name, status, created_at, provisioned_by_tenant)
1360
+ VALUES (?, ?, ?, 'active', ?, ?)`, id, slug, name, createdAt, provisionedByTenant).rowsWritten > 0, audit && (() => ({ ...audit, after: this.readTenant(id) })));
1335
1361
  return this.readTenant(id) ?? null;
1336
1362
  }
1337
- /** Throw if absent; else UPDATE and return the previous status. */
1338
- setTenantStatus(tenantId, status) {
1363
+ /** Throw if absent; else UPDATE and return the previous status, which `audit`'s `before` records. */
1364
+ setTenantStatus(tenantId, status, audit) {
1339
1365
  const before = this.readTenant(tenantId);
1340
1366
  if (!before)
1341
1367
  throw substratError('not_found', `unknown tenant: ${tenantId}`);
@@ -1348,15 +1374,13 @@ export class ControlPlaneDO extends DurableObject {
1348
1374
  // Stamp/clear deleting_at so the grace-window sweep can age tenants (§4.8):
1349
1375
  // entering `deleting` stamps, leaving it (an un-delete) clears — the same shape
1350
1376
  // transitionScope uses for archived_at.
1351
- this.ctx.storage.transactionSync(() => {
1352
- if (status === 'deleting') {
1353
- this.sql.exec('UPDATE tenants SET status = ?, deleting_at = ? WHERE tenant_id = ?', status, new Date().toISOString(), tenantId);
1354
- }
1355
- else {
1356
- this.sql.exec('UPDATE tenants SET status = ?, deleting_at = NULL WHERE tenant_id = ?', status, tenantId);
1357
- }
1377
+ this.writeAndAudit(() => {
1378
+ const moved = (status === 'deleting'
1379
+ ? this.sql.exec('UPDATE tenants SET status = ?, deleting_at = ? WHERE tenant_id = ?', status, new Date().toISOString(), tenantId)
1380
+ : this.sql.exec('UPDATE tenants SET status = ?, deleting_at = NULL WHERE tenant_id = ?', status, tenantId)).rowsWritten > 0;
1358
1381
  this.bumpLifecycleRevision(`tenant:${tenantId}`); // #1713, with the change it counts
1359
- });
1382
+ return moved;
1383
+ }, audit && { ...audit, before: { status: before.status } });
1360
1384
  return before.status;
1361
1385
  }
1362
1386
  /**
@@ -1367,7 +1391,7 @@ export class ControlPlaneDO extends DurableObject {
1367
1391
  * previous status (for the audit before/after). Idempotent: set-to-empty DELETEs
1368
1392
  * plus the status flip converge on a re-run after a partial failure.
1369
1393
  */
1370
- reapTenant(tenantId) {
1394
+ reapTenant(tenantId, audit) {
1371
1395
  const before = this.readTenant(tenantId);
1372
1396
  if (!before)
1373
1397
  throw substratError('not_found', `unknown tenant: ${tenantId}`);
@@ -1376,40 +1400,47 @@ export class ControlPlaneDO extends DurableObject {
1376
1400
  }
1377
1401
  // KEPT as the tombstone: the `tenants` row itself and `_substrat_admin_log` (the
1378
1402
  // compliance witness — never swept). Scope rows were already reaped individually.
1379
- for (const table of [
1380
- '_substrat_identities', // PII: external subjects/emails bound to principals
1381
- '_substrat_identity_pools', // the tenant's IdP topology declarations
1382
- '_substrat_tenant_tuples', // membership + tenant-level grants
1383
- '_substrat_roles', // operator-defined roles
1384
- '_substrat_entitlements', // per-tenant SKU flags
1385
- 'orgs', // K-22 org records
1386
- '_substrat_system_switches', // #1674: the schedule switch's record, per scope
1387
- '_substrat_membership_fences', // #1184: the latest removal, per principal
1388
- '_substrat_peer_switches', // #2029: the peer switch's record, per scope
1389
- 'peer_bindings', // #1720: a tenant's explicit caller-to-target choices
1390
- '_substrat_switch_owed', // #2045: subjects owed a re-assert, per scope
1391
- '_substrat_findings', // #1748: the tenant's findings
1392
- '_substrat_finding_rules', // #1748: the tenant's suppress rules
1393
- '_substrat_scope_storage', // #1524: storage samples of scopes whose storage is gone
1394
- '_substrat_scope_storage_attempts', // #1524: and the phase's last try at each
1395
- '_substrat_scope_store_storage', // #2212: scope-store samples, gone with the tenant
1396
- ]) {
1397
- this.sql.exec(`DELETE FROM ${table} WHERE tenant_id = ?`, tenantId);
1398
- }
1399
- this.ctx.storage.transactionSync(() => {
1400
- this.sql.exec("UPDATE tenants SET status = 'reaped', deleting_at = NULL WHERE tenant_id = ?", tenantId);
1403
+ // One unit with the flip and `audit`, whose `before` is the status read here.
1404
+ this.writeAndAudit(() => {
1405
+ for (const table of [
1406
+ '_substrat_identities', // PII: external subjects/emails bound to principals
1407
+ '_substrat_identity_pools', // the tenant's IdP topology declarations
1408
+ '_substrat_tenant_tuples', // membership + tenant-level grants
1409
+ '_substrat_roles', // operator-defined roles
1410
+ '_substrat_entitlements', // per-tenant SKU flags
1411
+ 'orgs', // K-22 org records
1412
+ '_substrat_system_switches', // #1674: the schedule switch's record, per scope
1413
+ '_substrat_membership_fences', // #1184: the latest removal, per principal
1414
+ '_substrat_peer_switches', // #2029: the peer switch's record, per scope
1415
+ 'peer_bindings', // #1720: a tenant's explicit caller-to-target choices
1416
+ '_substrat_switch_owed', // #2045: subjects owed a re-assert, per scope
1417
+ '_substrat_findings', // #1748: the tenant's findings
1418
+ '_substrat_finding_rules', // #1748: the tenant's suppress rules
1419
+ '_substrat_scope_storage', // #1524: storage samples of scopes whose storage is gone
1420
+ '_substrat_scope_storage_attempts', // #1524: and the phase's last try at each
1421
+ '_substrat_scope_store_storage', // #2212: scope-store samples, gone with the tenant
1422
+ '_substrat_scope_attachment_storage', // #2501: attachment samples, gone with the tenant
1423
+ ]) {
1424
+ this.sql.exec(`DELETE FROM ${table} WHERE tenant_id = ?`, tenantId);
1425
+ }
1426
+ const reaped = this.sql.exec("UPDATE tenants SET status = 'reaped', deleting_at = NULL WHERE tenant_id = ?", tenantId).rowsWritten > 0;
1401
1427
  // Its scopes were reaped one by one, each forgetting its own; any left over go here.
1402
1428
  this.forgetLifecycleDeliveries('scope_id IN (SELECT scope_id FROM scopes WHERE tenant_id = ?)', tenantId);
1403
1429
  this.bumpLifecycleRevision(`tenant:${tenantId}`); // #1713
1404
- });
1430
+ return reaped;
1431
+ }, audit && { ...audit, before: { status: before.status } });
1405
1432
  return before.status;
1406
1433
  }
1407
- /** Rename the DISPLAY name (never the slug); throw if absent; return the previous name. */
1408
- setTenantName(tenantId, name) {
1434
+ /**
1435
+ * Rename the DISPLAY name (never the slug); throw if absent; return the previous name. A rename
1436
+ * to the name it already has writes nothing, `audit` included; otherwise `audit`'s `before` is
1437
+ * the name read here.
1438
+ */
1439
+ setTenantName(tenantId, name, audit) {
1409
1440
  const before = this.readTenant(tenantId);
1410
1441
  if (!before)
1411
1442
  throw substratError('not_found', `unknown tenant: ${tenantId}`);
1412
- this.sql.exec('UPDATE tenants SET name = ? WHERE tenant_id = ?', name, tenantId);
1443
+ this.writeAndAudit(() => before.name !== name && this.sql.exec('UPDATE tenants SET name = ? WHERE tenant_id = ?', name, tenantId).rowsWritten > 0, audit && { ...audit, before: { name: before.name } });
1413
1444
  return before.name;
1414
1445
  }
1415
1446
  getTenant(tenantId) {
@@ -1805,20 +1836,53 @@ export class ControlPlaneDO extends DurableObject {
1805
1836
  if (to === 'reaped') {
1806
1837
  forgetSwitchesOf(this.kernelSql, scopeId);
1807
1838
  this.forgetLifecycleDeliveries('scope_id = ?', scopeId);
1808
- // #1524: its storage is gone, so its samples describe nothing.
1809
- forgetScopeStorage(doRedactionSql(this.sql), scopeId);
1839
+ // #2515: its storage is gone, and its measured days are kept for the invoice (retention ends them).
1840
+ retireScopeStorage(doRedactionSql(this.sql), scopeId);
1810
1841
  }
1811
1842
  this.bumpLifecycleRevision(`scope:${scopeId}`); // #1713, with the status it counts
1812
1843
  });
1813
1844
  return { ok: true, status: row.status, vertical: row.vertical };
1814
1845
  }
1815
1846
  // -- roles (checker rule 1) -------------------------------------------------
1816
- /** INSERT OR REPLACE; return the previous role (for the audit `before`) or null. */
1847
+ /**
1848
+ * Kept for a coordinator from before #2447, which calls it and reads the previous role (for the
1849
+ * audit `before`) or null. The definition is judged as `defineRoleOrRefusal` judges it, and a
1850
+ * refusal is THROWN, so such a coordinator cannot read it as the previous role and audit a change
1851
+ * that never happened.
1852
+ */
1817
1853
  defineRole(tenantId, role) {
1854
+ const outcome = this.defineRoleOrRefusal(tenantId, role);
1855
+ if ('refused' in outcome)
1856
+ throw new Error(outcome.refused);
1857
+ return outcome.before;
1858
+ }
1859
+ /**
1860
+ * INSERT OR REPLACE; answer the previous role (for the audit `before`) or null. Judged first, in
1861
+ * the same unit (#2447, `reservedRoleDefinitionRefusal`): against `reservation`, what the
1862
+ * coordinator's modules reserve and declare, and the tenant's roles as stored now, so no
1863
+ * concurrent definition lands between the read and the write. Whether the key is seated decides a
1864
+ * system-held definition of a key with no system-held definition stored (none at all included): its tenant-level seats are read here, in the unit, and
1865
+ * `seatedInScopes` carries what the coordinator read off the tenant's scopes. A refusal is answered
1866
+ * as data, so its text survives the hop, and writes nothing. The current coordinator calls this one
1867
+ * (the same split as `transitionScope` and `transitionScopeOrRefusal`).
1868
+ */
1869
+ defineRoleOrRefusal(tenantId, role, reservation = { reserved: [], declared: [] }, seatedInScopes = false) {
1870
+ const tenantRoles = this.listRoles({ tenantId }).map((r) => ({
1871
+ key: r.role_key,
1872
+ permissions: JSON.parse(r.permissions),
1873
+ holder: r.holder ?? undefined,
1874
+ }));
1875
+ const stored = tenantRoles.find((r) => r.key === role.key);
1876
+ const seated = stored?.holder !== 'system' && role.holder === 'system' &&
1877
+ (seatedInScopes ||
1878
+ this.sql.exec(TENANT_SEATED_ROLES_SQL, tenantId, new Date().toISOString()).toArray().some((r) => r.role_key === role.key));
1879
+ const refused = reservedRoleDefinitionRefusal(role, { reserved: new Set(reservation.reserved), declared: new Set(reservation.declared) }, tenantRoles, seated);
1880
+ if (refused)
1881
+ return { refused };
1818
1882
  const before = this.getRole(tenantId, role.key) ?? null;
1819
1883
  this.sql.exec(`INSERT OR REPLACE INTO _substrat_roles (tenant_id, role_key, permissions, source, holder)
1820
1884
  VALUES (?, ?, ?, ?, ?)`, tenantId, role.key, JSON.stringify(role.permissions), String(role.source), role.holder ?? 'person');
1821
- return before;
1885
+ return { before };
1822
1886
  }
1823
1887
  /** Raw rows; the coordinator parses them through the `tenantRole` contract. */
1824
1888
  listRoles(filter) {
@@ -2149,8 +2213,14 @@ export class ControlPlaneDO extends DurableObject {
2149
2213
  .exec('SELECT * FROM verticals WHERE slug = ?', slug)
2150
2214
  .toArray()[0];
2151
2215
  }
2152
- insertVertical(slug, name, source, ownerTenant, envSpec, installSpec, listed, createdAt) {
2153
- this.sql.exec('INSERT INTO verticals (slug, name, source, owner_tenant, env_spec, install_spec, listed, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', slug, name, source, ownerTenant, envSpec, installSpec, listed, createdAt);
2216
+ insertVertical(slug, name, source, ownerTenant, envSpec, installSpec, listed, createdAt, audit) {
2217
+ // #2501: the slug's ownership period opens with its row, in the same transaction.
2218
+ this.writeAndAudit(() => {
2219
+ const changed = this.sql.exec('INSERT INTO verticals (slug, name, source, owner_tenant, env_spec, install_spec, listed, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', slug, name, source, ownerTenant, envSpec, installSpec, listed, createdAt).rowsWritten > 0;
2220
+ if (changed)
2221
+ openVerticalOwnerRow(doRedactionSql(this.sql), slug, ownerTenant, createdAt);
2222
+ return changed;
2223
+ }, audit);
2154
2224
  }
2155
2225
  /** Refresh a vertical's declared env-spec + install-spec (both evolve with the manifest).
2156
2226
  * `listed` rides along ONLY for builtin re-seeds (null ⇒ leave it alone): a pushed
@@ -2161,25 +2231,55 @@ export class ControlPlaneDO extends DurableObject {
2161
2231
  this.sql.exec('UPDATE verticals SET listed = ? WHERE slug = ?', listed, slug);
2162
2232
  }
2163
2233
  }
2164
- /** Publish/unpublish a vertical to the public marketplace + resolve any pending request. */
2165
- updateVerticalListed(slug, listed) {
2166
- this.sql.exec('UPDATE verticals SET listed = ?, publish_requested_at = NULL WHERE slug = ?', listed, slug);
2234
+ /**
2235
+ * Publish/unpublish a vertical to the public marketplace + resolve any pending request. Listing
2236
+ * refuses a prod version that is auto-admitted (only its author has read it), judged on the
2237
+ * channel as this unit reads it (#2528).
2238
+ */
2239
+ updateVerticalListed(slug, listed, audit) {
2240
+ this.updateVertical(slug, 'listed = ?, publish_requested_at = NULL', [listed], (r) => {
2241
+ if (listed) {
2242
+ const prod = this.sql
2243
+ .exec(`SELECT v.id, v.admission_note FROM vertical_channels c JOIN vertical_versions v ON v.id = c.version_id
2244
+ WHERE c.vertical_slug = ? AND c.channel = 'prod'`, slug)
2245
+ .toArray()[0];
2246
+ if (prod?.admission_note === AUTO_ADMISSION_NOTE) {
2247
+ throw substratError('conflict', `vertical '${slug}' prod version ${prod.id} is auto-admitted (private self-serve) — ` +
2248
+ `a staff admit must vouch for it before listing`);
2249
+ }
2250
+ }
2251
+ return { listed: !!r.listed };
2252
+ }, audit);
2167
2253
  }
2168
2254
  /** Record a builder's pending publish request (marketplace-publish.md §5). */
2169
- updateVerticalPublishRequest(slug, requestedAt) {
2170
- this.sql.exec('UPDATE verticals SET publish_requested_at = ? WHERE slug = ?', requestedAt, slug);
2255
+ updateVerticalPublishRequest(slug, requestedAt, audit) {
2256
+ this.updateVertical(slug, 'publish_requested_at = ?', [requestedAt], () => null, audit);
2171
2257
  }
2172
2258
  /** Block/unblock new installs of a vertical (staff kill-switch). */
2173
- updateVerticalInstallsBlocked(slug, blocked) {
2174
- this.sql.exec('UPDATE verticals SET installs_blocked = ? WHERE slug = ?', blocked, slug);
2259
+ updateVerticalInstallsBlocked(slug, blocked, audit) {
2260
+ this.updateVertical(slug, 'installs_blocked = ?', [blocked], (r) => ({ installsBlocked: !!r.installs_blocked }), audit);
2175
2261
  }
2176
2262
  /** Grant/revoke the tenant-provisioner capability (#412, staff grant). */
2177
- updateVerticalTenantProvisioner(slug, granted) {
2178
- this.sql.exec('UPDATE verticals SET tenant_provisioner = ? WHERE slug = ?', granted, slug);
2263
+ updateVerticalTenantProvisioner(slug, granted, audit) {
2264
+ this.updateVertical(slug, 'tenant_provisioner = ?', [granted], (r) => ({ tenantProvisioner: !!r.tenant_provisioner }), audit);
2179
2265
  }
2180
2266
  /** Grant/revoke the email-sender capability (#303, staff grant). */
2181
- updateVerticalEmailSender(slug, granted) {
2182
- this.sql.exec('UPDATE verticals SET email_sender = ? WHERE slug = ?', granted, slug);
2267
+ updateVerticalEmailSender(slug, granted, audit) {
2268
+ this.updateVertical(slug, 'email_sender = ?', [granted], (r) => ({ emailSender: !!r.email_sender }), audit);
2269
+ }
2270
+ /**
2271
+ * #2528: one vertical row update whose row reads its `before` (and runs its guard, by throwing)
2272
+ * from the vertical as this unit reads it, so neither can be stale against the write.
2273
+ */
2274
+ updateVertical(slug, set, args, judge, audit) {
2275
+ let before = null;
2276
+ this.writeAndAudit(() => {
2277
+ const r = this.readVertical(slug);
2278
+ if (!r)
2279
+ throw substratError('not_found', `unknown vertical '${slug}'`);
2280
+ before = judge(r);
2281
+ return this.sql.exec(`UPDATE verticals SET ${set} WHERE slug = ?`, ...args, slug).rowsWritten > 0;
2282
+ }, audit && (() => ({ ...audit, before })));
2183
2283
  }
2184
2284
  /**
2185
2285
  * How many scopes a vertical still backs, split by liveness — deleteVertical's refusal
@@ -2198,17 +2298,48 @@ export class ControlPlaneDO extends DurableObject {
2198
2298
  return { live: r?.live ?? 0, archived: r?.archived ?? 0 };
2199
2299
  }
2200
2300
  /**
2201
- * Remove a vertical's registry presence: channels, versions, the row. The bound-scope
2202
- * refusal lives host-side (the audited seam), not here — this is the storage verb.
2301
+ * Remove a vertical's registry presence: channels, versions, the row. Refused while any
2302
+ * restorable scope is bound (`countScopesForVertical`): a deleted registry row would strand
2303
+ * those scopes' version pins and routing. That refusal and `audit`'s `before` are read in the
2304
+ * unit that deletes (#2528).
2203
2305
  */
2204
- deleteVertical(slug) {
2205
- this.sql.exec('DELETE FROM vertical_channels WHERE vertical_slug = ?', slug);
2206
- this.sql.exec('DELETE FROM vertical_channel_history WHERE vertical_slug = ?', slug);
2207
- this.sql.exec(`DELETE FROM vertical_version_migrations
2208
- WHERE version_id IN (SELECT id FROM vertical_versions WHERE vertical_slug = ?)`, slug);
2209
- forgetVerticalScopeStores(this.kernelSql, slug);
2210
- this.sql.exec('DELETE FROM vertical_versions WHERE vertical_slug = ?', slug);
2211
- this.sql.exec('DELETE FROM verticals WHERE slug = ?', slug);
2306
+ deleteVertical(slug, audit, until) {
2307
+ // #2501: one transaction, so the row and its ownership period close together. `until` is the
2308
+ // host's stamp, on the clock the period's `since` (the row's created_at) was read on. It is
2309
+ // optional and trailing so a caller from before it, sending (slug, audit), still closes the
2310
+ // period, on this DO's own reading of that clock; anything but an instant is refused.
2311
+ const closedAt = until === undefined ? verticalOwnerNow() : instant.parse(until);
2312
+ let before = null;
2313
+ this.writeAndAudit(() => {
2314
+ const existing = this.readVertical(slug);
2315
+ if (!existing)
2316
+ throw substratError('not_found', `unknown vertical '${slug}'`);
2317
+ // An `archived` scope (a deleted app) still blocks — unarchive can bring it back — but the
2318
+ // refusal names reap/restore, because the app itself is already gone.
2319
+ const bound = this.countScopesForVertical(slug);
2320
+ if (bound.live > 0) {
2321
+ throw substratError('conflict', `vertical '${slug}' still backs ${bound.live} scope(s) — delete or rebind them first`);
2322
+ }
2323
+ if (bound.archived > 0) {
2324
+ throw substratError('conflict', `vertical '${slug}' still backs ${bound.archived} archived scope(s) — reap or restore them first`);
2325
+ }
2326
+ before = { slug, source: existing.source, ownerTenant: existing.owner_tenant };
2327
+ this.sql.exec('DELETE FROM vertical_channels WHERE vertical_slug = ?', slug);
2328
+ this.sql.exec('DELETE FROM vertical_channel_history WHERE vertical_slug = ?', slug);
2329
+ this.sql.exec(`DELETE FROM vertical_version_migrations
2330
+ WHERE version_id IN (SELECT id FROM vertical_versions WHERE vertical_slug = ?)`, slug);
2331
+ forgetVerticalScopeStores(this.kernelSql, slug);
2332
+ forgetVerticalWireAuth(this.kernelSql, slug);
2333
+ this.sql.exec('DELETE FROM vertical_versions WHERE vertical_slug = ?', slug);
2334
+ const changed = this.sql.exec('DELETE FROM verticals WHERE slug = ?', slug).rowsWritten > 0;
2335
+ if (changed)
2336
+ closeVerticalOwnerRow(doRedactionSql(this.sql), slug, closedAt);
2337
+ return changed;
2338
+ }, audit && (() => ({ ...audit, before })));
2339
+ }
2340
+ /** #2501: every period of a slug's ownership, oldest first — kernel `vertical-owners.ts`. */
2341
+ listVerticalOwners(slug) {
2342
+ return listVerticalOwnerRows(doRedactionSql(this.sql), slug);
2212
2343
  }
2213
2344
  listVerticals(page) {
2214
2345
  const where = [];
@@ -2234,17 +2365,27 @@ export class ControlPlaneDO extends DurableObject {
2234
2365
  * the SQL already split off (`splitManifestMigrations`); `migrations` is null for a version
2235
2366
  * that carries none to show.
2236
2367
  */
2237
- insertVersion(v) {
2238
- this.ctx.storage.transactionSync(() => {
2368
+ insertVersion(v, audit) {
2369
+ // #2528: whether it self-admits is judged on the vertical as this unit reads it — a PRIVATE
2370
+ // vertical (tenant-owned, not listed) self-admits, noted so the publish seam can tell a staff
2371
+ // vouch from this shortcut — whatever the caller judged from its own earlier read.
2372
+ let admission = v.admission;
2373
+ this.writeAndAudit(() => {
2374
+ const owning = this.readVertical(v.verticalSlug);
2375
+ if (!owning)
2376
+ throw substratError('not_found', `unknown vertical '${v.verticalSlug}'`);
2377
+ const selfAdmits = owning.owner_tenant !== null && !owning.listed;
2378
+ admission = selfAdmits ? 'admitted' : 'pending';
2239
2379
  this.sql.exec(`INSERT INTO vertical_versions
2240
2380
  (id, vertical_slug, version, manifest_digest, permission_digest,
2241
2381
  migration_digest, deployment_ref, admission, admission_note, manifest_json,
2242
2382
  origin_json, migration_count, migrations_split, migration_digest_derived, created_at)
2243
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?)`, v.id, v.verticalSlug, v.version, v.manifestDigest, v.permissionDigest, v.migrationDigest, v.deploymentRef, v.admission, v.admissionNote, v.manifestJson, v.originJson, v.migrations?.length ?? null, v.createdAt);
2383
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, ?)`, v.id, v.verticalSlug, v.version, v.manifestDigest, v.permissionDigest, v.migrationDigest, v.deploymentRef, admission, selfAdmits ? AUTO_ADMISSION_NOTE : null, v.manifestJson, v.originJson, v.migrations?.length ?? null, v.createdAt);
2244
2384
  writeVersionMigrations(this.kernelSql, v.id, v.migrations);
2245
2385
  // #2286: the version's scope-store bindings, refusing one moved to another driver, in the insert's transaction.
2246
2386
  indexVersionScopeStores(this.kernelSql, v.verticalSlug, v.id, v.manifestJson);
2247
- });
2387
+ return true;
2388
+ }, audit && (() => ({ ...audit, after: { ...audit.after, admission } })));
2248
2389
  }
2249
2390
  /** Every scope-store binding any version of the vertical declared (#2286); indexes versions not indexed yet. */
2250
2391
  verticalScopeStores(slug) {
@@ -2254,11 +2395,46 @@ export class ControlPlaneDO extends DurableObject {
2254
2395
  readVersionMigrations(id) {
2255
2396
  return versionMigrationsOf(this.kernelSql, id);
2256
2397
  }
2257
- /** Record what the serving script now runs — written only after a successful
2258
- * in-place upload, so a failed serve leaves the trailing state visible. */
2259
- setVerticalServing(slug, s) {
2260
- this.sql.exec(`UPDATE verticals SET serving_ref = ?, serving_version_id = ?,
2261
- serving_do_classes = ?, serving_migration_tag = ? WHERE slug = ?`, s.ref, s.versionId, s.doClassesJson, s.migrationTag, slug);
2398
+ /**
2399
+ * Record what the serving script now runs — written only after a successful in-place upload, so
2400
+ * a failed serve leaves the trailing state visible. #2528: `expected` is the serving ref and
2401
+ * version the caller's #2178 gate judged the move from (nulls: nothing served); the write lands
2402
+ * only if the row still names both (`SERVES_NOW_CONDITION`), and `audit`'s `before` is the
2403
+ * serving state this unit read. False when either moved. Optional and trailing, so a caller from
2404
+ * before it still writes unconditionally.
2405
+ */
2406
+ setVerticalServing(slug, s, audit, expected) {
2407
+ let before = null;
2408
+ let raced = false;
2409
+ this.writeAndAudit(() => {
2410
+ const r = this.readVertical(slug);
2411
+ if (!r)
2412
+ throw substratError('not_found', `unknown vertical '${slug}'`);
2413
+ before = r.serving_ref ? { ref: r.serving_ref, versionId: r.serving_version_id } : null;
2414
+ const moved = this.sql.exec(`UPDATE verticals SET serving_ref = ?, serving_version_id = ?,
2415
+ serving_do_classes = ?, serving_migration_tag = ?
2416
+ 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;
2417
+ raced = !moved;
2418
+ return moved;
2419
+ }, audit && (() => ({ ...audit, before })));
2420
+ return !raced;
2421
+ }
2422
+ /**
2423
+ * One upload's capability record (RFC wire-signing §7, Q8), replacing the script's earlier one. False,
2424
+ * with nothing written and no admin row, for a vertical the registry does not hold: the host answers
2425
+ * that `not_found` on its side of the RPC, so the code survives.
2426
+ */
2427
+ recordWireAuth(row, audit) {
2428
+ return this.writeAndAudit(() => writeDeploymentWireAuth(this.kernelSql, row), audit);
2429
+ }
2430
+ /**
2431
+ * One page of the stored capability records the filter's reach confines it to (one tenant's
2432
+ * verticals, or the fleet), by script name. The host judged the filter before the call; it is judged
2433
+ * again here (`deploymentWireAuthOf`), so no caller of the object reaches the table unconfined or
2434
+ * unbounded.
2435
+ */
2436
+ listWireAuth(filter) {
2437
+ return deploymentWireAuthOf(this.kernelSql, filter);
2262
2438
  }
2263
2439
  /** Point a scope's routing at the serving script its data now lives in (#286). False when a
2264
2440
  * conditional move lost its compare-and-set: the host throws, since a refusal thrown here
@@ -2297,8 +2473,54 @@ export class ControlPlaneDO extends DurableObject {
2297
2473
  await deriveVersionMigrationDigest(this.kernelSql, r.id);
2298
2474
  return read();
2299
2475
  }
2300
- setAdmission(id, admission, note) {
2301
- this.sql.exec('UPDATE vertical_versions SET admission = ?, admission_note = ? WHERE id = ?', admission, note, id);
2476
+ /** Kept for a coordinator from before #2528, which judged the admission itself. */
2477
+ setAdmission(id, admission, note, audit) {
2478
+ this.writeAndAudit(() => this.sql.exec('UPDATE vertical_versions SET admission = ?, admission_note = ? WHERE id = ?', admission, note, id).rowsWritten > 0, audit);
2479
+ }
2480
+ /**
2481
+ * Admit a version, judged on its admission as this unit reads it (#2528). Idempotent on an
2482
+ * admitted one — except an AUTO-admitted version, which this upgrades to a manual vouch by
2483
+ * clearing the note (what the publish seam requires). A rejected one is refused.
2484
+ */
2485
+ admitVersion(id, audit) {
2486
+ let row = { before: null, after: null };
2487
+ this.writeAndAudit(() => {
2488
+ const v = this.versionAdmission(id);
2489
+ if (v.admission === 'admitted') {
2490
+ if (v.admission_note !== AUTO_ADMISSION_NOTE)
2491
+ return false;
2492
+ row = { before: { admission: v.admission, note: v.admission_note }, after: { admission: 'admitted', note: null } };
2493
+ }
2494
+ else if (v.admission === 'rejected') {
2495
+ throw substratError('conflict', `version ${id} was rejected — publish a new one`);
2496
+ }
2497
+ else {
2498
+ row = { before: { admission: v.admission }, after: { admission: 'admitted' } };
2499
+ }
2500
+ return this.sql.exec("UPDATE vertical_versions SET admission = 'admitted', admission_note = NULL WHERE id = ?", id)
2501
+ .rowsWritten > 0;
2502
+ }, audit && (() => ({ ...audit, ...row })));
2503
+ }
2504
+ /** Reject a version, judged as `admitVersion` is: an admitted one is refused, a rejected one is a no-op. */
2505
+ rejectVersion(id, note, audit) {
2506
+ let before = null;
2507
+ this.writeAndAudit(() => {
2508
+ const v = this.versionAdmission(id);
2509
+ if (v.admission === 'admitted')
2510
+ throw substratError('conflict', `version ${id} is already admitted — it may be bound`);
2511
+ if (v.admission === 'rejected')
2512
+ return false;
2513
+ before = { admission: v.admission };
2514
+ return this.sql.exec("UPDATE vertical_versions SET admission = 'rejected', admission_note = ? WHERE id = ?", note, id)
2515
+ .rowsWritten > 0;
2516
+ }, audit && (() => ({ ...audit, before, after: { admission: 'rejected', note } })));
2517
+ }
2518
+ versionAdmission(id) {
2519
+ const v = this.sql.exec('SELECT admission, admission_note FROM vertical_versions WHERE id = ?', id)
2520
+ .toArray()[0];
2521
+ if (!v)
2522
+ throw substratError('not_found', `unknown version ${id}`);
2523
+ return v;
2302
2524
  }
2303
2525
  /**
2304
2526
  * False when a conditional bind lost its compare-and-set (the host throws, as above). #2347:
@@ -2338,12 +2560,15 @@ export class ControlPlaneDO extends DurableObject {
2338
2560
  return true;
2339
2561
  }, audit);
2340
2562
  }
2341
- /** The directory effect and its admin row either both commit or both roll back. */
2563
+ /**
2564
+ * The directory effect and its admin row either both commit or both roll back. A thunk is
2565
+ * read after the write, in the same unit, for a row that carries what only the write knows.
2566
+ */
2342
2567
  writeAndAudit(write, audit) {
2343
2568
  return this.ctx.storage.transactionSync(() => {
2344
2569
  const changed = write();
2345
2570
  if (changed && audit)
2346
- this.recordAdmin(audit);
2571
+ this.recordAdmin(typeof audit === 'function' ? audit() : audit);
2347
2572
  return changed;
2348
2573
  });
2349
2574
  }
@@ -2521,10 +2746,10 @@ export class ControlPlaneDO extends DurableObject {
2521
2746
  * #2333: move a channel's pointer, and record the move, only if it still names `from` (null: the
2522
2747
  * channel has none yet). A compare-and-set in one synchronous transaction, so no other promote
2523
2748
  * runs between the read and the write: a promote judged its gates against `from` across awaits,
2524
- * and the one that lost the race is told so (`false`) and writes nothing.
2749
+ * and the one that lost the race is told so (`false`) and writes nothing, `audit` included.
2525
2750
  */
2526
- moveChannel(from, history) {
2527
- return this.ctx.storage.transactionSync(() => {
2751
+ moveChannel(from, history, audit) {
2752
+ return this.writeAndAudit(() => {
2528
2753
  const now = this.readChannel(history.vertical_slug, history.channel);
2529
2754
  if ((now?.version_id ?? null) !== from)
2530
2755
  return false;
@@ -2533,7 +2758,7 @@ export class ControlPlaneDO extends DurableObject {
2533
2758
  ON CONFLICT (vertical_slug, channel) DO UPDATE SET version_id = ?, updated_at = ?`, history.vertical_slug, history.channel, history.version_id, history.at, history.version_id, history.at);
2534
2759
  this.insertChannelHistory(history);
2535
2760
  return true;
2536
- });
2761
+ }, audit);
2537
2762
  }
2538
2763
  listChannels(verticalSlug, page) {
2539
2764
  const where = ['vertical_slug = ?'];
@@ -2819,6 +3044,7 @@ export class ControlPlaneDO extends DurableObject {
2819
3044
  storage: listScopeStorageRows(doRedactionSql(this.sql), { tenantId: tenantId, latest: true }),
2820
3045
  storageAttempts: listScopeStorageAttemptRows(doRedactionSql(this.sql), tenantId),
2821
3046
  storeStorage: listScopeStoreStorageRows(doRedactionSql(this.sql), tenantId),
3047
+ attachmentStorage: listScopeAttachmentStorageRows(doRedactionSql(this.sql), tenantId),
2822
3048
  };
2823
3049
  }
2824
3050
  // -- the stored storage gauge (#1524) — kernel `storage-gauge.ts` --------------
@@ -2834,6 +3060,33 @@ export class ControlPlaneDO extends DurableObject {
2834
3060
  pruneScopeStorage(limit) {
2835
3061
  return pruneScopeStorageRows(doRedactionSql(this.sql), Date.now(), limit);
2836
3062
  }
3063
+ // -- the daily request rollup (#2501) — kernel `request-rollup.ts` -------------
3064
+ recordRequestDay(input) {
3065
+ return { recorded: this.ctx.storage.transactionSync(() => recordRequestDayRows(doRedactionSql(this.sql), input)) };
3066
+ }
3067
+ listRequestDays(filter) {
3068
+ return listRequestDayRows(doRedactionSql(this.sql), filter);
3069
+ }
3070
+ listRequestRollupDays(filter) {
3071
+ return listRequestRollupDayRows(doRedactionSql(this.sql), filter);
3072
+ }
3073
+ pruneRequestDays(limit, retentionMonths) {
3074
+ return this.ctx.storage.transactionSync(() => pruneRequestDayRows(doRedactionSql(this.sql), Date.now(), limit, retentionMonths));
3075
+ }
3076
+ // -- the daily meter snapshot (#2501) — kernel `meter-days.ts` -------------------
3077
+ // One transaction: the snapshot's reads and its rows are one instant of the directory.
3078
+ recordMeterDay(input) {
3079
+ return this.ctx.storage.transactionSync(() => recordMeterDayRows(doRedactionSql(this.sql), input));
3080
+ }
3081
+ listMeterDays(filter) {
3082
+ return listMeterDayRows(doRedactionSql(this.sql), filter);
3083
+ }
3084
+ listMeterDayTakes(filter) {
3085
+ return listMeterDayTakeRows(doRedactionSql(this.sql), filter);
3086
+ }
3087
+ pruneMeterDays(limit, retentionMonths) {
3088
+ return this.ctx.storage.transactionSync(() => pruneMeterDayRows(doRedactionSql(this.sql), Date.now(), limit, retentionMonths));
3089
+ }
2837
3090
  // -- identity pools (K-23) --------------------------------------------------
2838
3091
  readPool(provider) {
2839
3092
  return this.sql
@@ -3607,9 +3860,15 @@ export class ControlPlaneDO extends DurableObject {
3607
3860
  }
3608
3861
  /** The body of `recordOpsFailure`, for a caller already inside a transaction. */
3609
3862
  insertOpsFailure(row) {
3610
- this.sql.exec(`INSERT INTO _substrat_ops_failures
3611
- (id, actor, operation, stage, tenant_id, scope_id, vertical, version, status, message, reference, origin, code, fingerprint, at)
3612
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, row.id, row.actor, row.operation, row.stage, row.tenant_id, row.scope_id, row.vertical, row.version, row.status, row.message, row.reference, row.origin, row.code, row.fingerprint, row.at);
3863
+ // #2540: a tenant's row with a `detail` is a pair — the tenant's row says what was answered, and
3864
+ // a row with no tenant, which only staff read, carries the failure's own text. Counted once.
3865
+ const detail = row.tenant_id && row.detail_id && typeof row.detail === 'string' ? row.detail.slice(0, 2000) : null;
3866
+ const insert = (id, tenantId, message) => this.sql.exec(`INSERT INTO _substrat_ops_failures
3867
+ (id, actor, operation, stage, tenant_id, scope_id, vertical, version, status, message, reference, origin, code, fingerprint, at)
3868
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, id, row.actor, row.operation, row.stage, tenantId, row.scope_id, row.vertical, row.version, row.status, message, row.reference, row.origin, row.code, row.fingerprint, row.at);
3869
+ if (detail !== null)
3870
+ insert(row.detail_id, null, detail);
3871
+ insert(row.id, row.tenant_id, row.message);
3613
3872
  // Prune-on-write (#559): the retention lives HERE, not in a cron — every insert
3614
3873
  // pays for its own housekeeping, so the table stays bounded even on a deployment
3615
3874
  // whose scheduled pass is broken (the exact circumstance this table records).
@@ -3632,7 +3891,11 @@ export class ControlPlaneDO extends DurableObject {
3632
3891
  last_vertical = COALESCE(excluded.last_vertical, last_vertical),
3633
3892
  last_version = COALESCE(excluded.last_version, last_version),
3634
3893
  origin = COALESCE(excluded.origin, origin),
3635
- status = CASE WHEN status = 'resolved' THEN 'regressed' ELSE status END`, row.fingerprint, row.operation, row.stage, row.origin, row.code, row.at, row.at, row.message, row.tenant_id, issueExemplarOwner(row.tenant_id), row.vertical, row.version);
3894
+ status = CASE WHEN status = 'resolved' THEN 'regressed' ELSE status END`, row.fingerprint, row.operation, row.stage, row.origin, row.code, row.at, row.at,
3895
+ // The issues are staff's (#2540): a pair's exemplar is its own text, not the tenant's
3896
+ // sentence, and it is owned as the row it is copied from is, the platform's — so erasure
3897
+ // reaches it on an id match, as it reaches that row.
3898
+ detail ?? row.message, detail !== null ? null : row.tenant_id, issueExemplarOwner(detail !== null ? null : row.tenant_id), row.vertical, row.version);
3636
3899
  const issueHorizon = new Date(Date.now() - ISSUE_RETENTION_DAYS * 86_400_000).toISOString();
3637
3900
  this.sql.exec('DELETE FROM _substrat_issues WHERE last_seen < ?', issueHorizon);
3638
3901
  // #1748: the tenant's Recurring finding, over the tenant's own occurrences only.
@@ -3688,8 +3951,9 @@ export class ControlPlaneDO extends DurableObject {
3688
3951
  params.push(query.fingerprint);
3689
3952
  }
3690
3953
  if (query.reference) {
3691
- where.push('reference = ?');
3692
- params.push(query.reference);
3954
+ const byReference = opsFailureReferenceWhere(query.reference, query.referenceInText === true);
3955
+ where.push(byReference.sql);
3956
+ params.push(...byReference.params);
3693
3957
  }
3694
3958
  if (query.since) {
3695
3959
  where.push('at >= ?');