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