@substrat-run/adapter-sqlite 0.114.0 → 0.117.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/index.js CHANGED
@@ -1,10 +1,25 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import Database from 'better-sqlite3';
4
- import { accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunEntry, modelUsageEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, createTenantInput, domainEvent, domainEventInput, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, platformRequest, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, subjectRef, requestFingerprint, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, routeTarget, registerVerticalInput, vertical as verticalSchema, verticalChannel, verticalVersion, objectRef, grantRefFromProof, org as orgSchema, orgMembership, principalId, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, SCOPE_TABLE_PAGE_DEFAULT, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, verticalServingState, outboundOfManifestJson, listLimitOf, substratError, assertReplayableDump, delegatedReadRecord, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
5
- import { asPrincipal, assertAllowed, assertReadOnlyQuery, attachmentBlobKey, entitlementDenial, foldMeterReading, guardSpine, parseValidationRecords, resolveScopeRecord, ulid, ISSUE_RETENTION_DAYS, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, MODEL_USAGE_RETENTION_DAYS, foldModelUsage, backoffAt, platformRequestHistoryQuery, PLATFORM_REQUEST_COLUMNS, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, ImpersonationRefused, assertImpersonationWrites, assertModuleEnqueueableKind, assertSessionUsable, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, impersonationStampOf, mapImpersonationRow, newImpersonationSession, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, noSealingKeyMessage, openSealed, sealTo, ConnectionSealingKeyUnavailableError, PermissionDenied, createAtomic, NotSearchable, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, searchLimit, searchMatchExpression, searchQuery, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, IDEMPOTENCY_DDL, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, globalFetch, createUlid, facetEvents, readHistory, walkEventCause, walkEventEffects, assertRedrainWindow, readInvocation, readDeadLetters, } from '@substrat-run/kernel';
4
+ import { accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunEntry, modelUsageEntry, ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, createTenantInput, domainEvent, domainEventInput, entitlementGrant, entitlementGrantInput, eventId, meterReading, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, identityLink, identityPool, instant, connection, capabilityGrant, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, subjectRef, requestFingerprint, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, createOrgInput, promotionAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, routeTarget, registerVerticalInput, vertical as verticalSchema, verticalChannel, verticalVersion, objectRef, grantRefFromProof, org as orgSchema, orgMembership, principalId, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, subjectShredReceipt, SCOPE_TABLE_PAGE_DEFAULT, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, verticalServingState, outboundOfManifestJson, listLimitOf, substratError, assertReplayableDump, delegatedReadRecord, redrainEventsInput, REDRAIN_BATCH, } from '@substrat-run/contracts';
5
+ import { actorOf, asPrincipal, assertAllowed, assertNoSecret, assertPermissionKey, CAPABILITY_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityTokenHash, createCapabilityVerbs, exchangeCapability as exchangeCapabilitySecret, guardSecrets, mintBecomeCapability, plausibleSessionToken, redactSecrets, resolveCapabilitySession, revokeCapabilityAsPlatform, assertReadOnlyQuery, attachmentBlobKey, entitlementDenial, foldMeterReading, guardSpine, parseValidationRecords, resolveScopeRecord, ulid, ISSUE_RETENTION_DAYS, OPS_FAILURE_RETENTION_DAYS, SWEEP_RUN_RETENTION_DAYS, SWEEP_RUNS_INTENT_INDEX, sweepRunsIntentHasKind, MODEL_USAGE_RETENTION_DAYS, foldModelUsage, backoffAt, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, seatScopeTuple, switchSystemSchedules, systemScheduleState, systemSwitchedOff, systemSwitchedOffMessage, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, IMPERSONATION_COLUMNS, IMPERSONATION_DDL, ImpersonationRefused, assertImpersonationWrites, assertModuleEnqueueableKind, assertSessionUsable, impersonationByIdQuery, impersonationListQuery, impersonationRowValues, impersonationStampOf, mapImpersonationRow, newImpersonationSession, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, noSealingKeyMessage, openSealed, sealTo, ConnectionSealingKeyUnavailableError, PermissionDenied, createAtomic, NotSearchable, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, searchLimit, searchMatchExpression, searchQuery, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, JOB_RUN_DDL, jobRunListLimit, jobRunOf, runDueJobRuns, startJobRun, IDEMPOTENCY_DDL, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, globalFetch, createUlid, facetEvents, readHistory, walkEventCause, walkEventEffects, assertRedrainWindow, readInvocation, readDeadLetters, domainEventOf, readUndrainedOutbox, undrainedEventsOf, } from '@substrat-run/kernel';
6
6
  import { ScopeActor } from './actor.js';
7
7
  import { createTupleChecker } from './checker.js';
8
+ /** The kernel's schedule-switch SQL (#1666), over one scope's database handle. */
9
+ const switchSqlOf = (db) => ({
10
+ all: (sql, ...params) => db.prepare(sql).all(...params),
11
+ run: (sql, ...params) => {
12
+ db.prepare(sql).run(...params);
13
+ },
14
+ });
15
+ /**
16
+ * How many scopes may hold a read connection at once (#1624). Every open scope keeps its
17
+ * write connection for the life of the host, so an uncapped reader per scope would double
18
+ * the file handles a node deployment holds — and the lake drain alone reads every scope.
19
+ * Past the cap the least recently read scope's reader is closed, and reopened on its next
20
+ * read, which costs well under a millisecond.
21
+ */
22
+ const READER_CAP = 32;
8
23
  /**
9
24
  * The scope spine, as this adapter builds it — one of two hand-written copies (#969).
10
25
  *
@@ -119,10 +134,39 @@ const KERNEL_DDL = `
119
134
  operation TEXT,
120
135
  -- K-42: WHICH of the two actors was refused is exactly what this log is for.
121
136
  impersonation TEXT,
137
+ -- #1525: the INVOCATION this refusal happened during, the same id #1237 stamps on
138
+ -- every event of a call. A denial joins to nothing but actor and time otherwise —
139
+ -- the operation column names what was attempted, never WHICH attempt — so "what
140
+ -- else did this request do" cannot reach a refusal, which is the one thing an
141
+ -- incident asks about first. NULL = the transport carried no id (a seed, a test,
142
+ -- an internal call, an attachment RPC), or the row predates the column.
143
+ invocation_id TEXT,
122
144
  at TEXT NOT NULL,
123
145
  drained_at TEXT
124
146
  );
125
147
  ${IDEMPOTENCY_DDL}
148
+ -- #1672: capabilities — authority carried by a secret (a link share), and the sessions
149
+ -- an exchange trades that secret for. Spine (kernel-written), shared with the DO adapter
150
+ -- from @substrat-run/kernel so the two cannot part company; the column comments are there.
151
+ ${CAPABILITY_DDL}
152
+ -- #383 / #1232 / #1288: the platform sweep's per-scope gating state, holding two
153
+ -- families of row that the kind COLUMN — not the spelling of a key — tells apart.
154
+ -- Spine (kernel-written), never a module migration. Shared with the DO adapter from
155
+ -- @substrat-run/kernel, so the shape a rebuild produces and the shape a fresh store
156
+ -- gets cannot part company; the column comments are in there.
157
+ --
158
+ -- HERE since #1288, rather than lazily in runDueSchedules/checkFreshness as it was:
159
+ -- a table only one adapter's KERNEL_DDL declares is a one-sided NOTE to
160
+ -- lint:spine-ddl, never a failure, so the copies this issue had to keep in step were
161
+ -- the copies the gate could not see. Every scope db passes through runtime(), which
162
+ -- runs this, so the lazy creates were load-bearing for nothing.
163
+ ${SCHEDULE_STATE_DDL}
164
+ -- #1577: the resumable-run driver's record and the step ledger of the pass it
165
+ -- currently has in flight. Spine (kernel-written), never a module migration.
166
+ -- Shared with the DO adapter from @substrat-run/kernel so the shape a self-host
167
+ -- builds and the shape production builds cannot part company; the column
168
+ -- comments, and the reason coalescing is NOT a unique index, are in there.
169
+ ${JOB_RUN_DDL}
126
170
  CREATE TABLE IF NOT EXISTS _substrat_migrations (
127
171
  module_id TEXT NOT NULL,
128
172
  version TEXT NOT NULL,
@@ -155,6 +199,17 @@ const KERNEL_DDL = `
155
199
  -- next_attempt_at IS NULL -> terminal: error IS NULL delivered, else dead
156
200
  attempts INTEGER NOT NULL DEFAULT 0,
157
201
  next_attempt_at TEXT,
202
+ -- #1525: the invocation THIS ATTEMPT ran in, the same id #1237 stamps on every
203
+ -- event of a call. Not the emitting event's — that one is already on the outbox
204
+ -- and a delivery joins to it through event_id. What that join cannot say is
205
+ -- which call attempted the DELIVERY, and for an executor the two differ by
206
+ -- design: attempt one runs in the emitting call's post-commit tail, every retry
207
+ -- afterwards in a drain that is a different call or none. Moves with the row on
208
+ -- an upsert, like delivered_at, error and attempts — it describes the
209
+ -- latest attempt, not the first. NULL = that attempt carried no call (a
210
+ -- scheduled drain, an alarm, a seed, an attachment RPC), or the row predates
211
+ -- the column.
212
+ invocation_id TEXT,
158
213
  PRIMARY KEY (event_id, consumer_module)
159
214
  );
160
215
  -- Attachment metadata facts (#473): one row per object in the per-tenant blob store,
@@ -294,23 +349,12 @@ function modelUsageEntryOf(r) {
294
349
  elapsedMs: r.elapsed_ms,
295
350
  });
296
351
  }
297
- /** Map a stored platform-request row to the `PlatformRequest` contract shape (JSON columns parsed). */
298
- function rowToPlatformRequest(r) {
299
- return platformRequest.parse({
300
- id: r.id,
301
- kind: r.kind,
302
- payload: JSON.parse(r.payload),
303
- requestedBy: JSON.parse(r.requested_by),
304
- impersonation: r.impersonation == null ? null : JSON.parse(r.impersonation),
305
- status: r.status,
306
- attempts: r.attempts,
307
- lastError: r.last_error,
308
- failure: r.last_failure == null ? null : JSON.parse(r.last_failure),
309
- result: r.result === null ? null : JSON.parse(r.result),
310
- requestedAt: r.requested_at,
311
- settledAt: r.settled_at,
312
- });
313
- }
352
+ /**
353
+ * The policy an undecodable event's executor delivery is journaled under (#1636): one
354
+ * attempt, so the first failure is the dead letter. A decode is pure — retrying it cannot
355
+ * succeed — and the Cloudflare coordinator records the same row with no next attempt.
356
+ */
357
+ const UNDECODABLE_RETRY = { maxAttempts: 1, baseDelayMs: 0, maxDelayMs: 0 };
314
358
  /**
315
359
  * The dispatch capability (#726 remedy B): a connector delivery may read the attachments
316
360
  * of the entity the DELIVERED EVENT names, and nothing else.
@@ -350,11 +394,39 @@ const admitByDelivery = (rt, eventId, row) => {
350
394
  };
351
395
  export class SqliteScopeHost {
352
396
  admin;
397
+ /**
398
+ * Live reads are not available on this host (#938) — `never`, not `undefined`.
399
+ *
400
+ * This is the `clock?: never` seam (`CloudflareScopeHostOptions`) pointing the other
401
+ * way. There, the hosted adapter refuses an option it could only honour for half of
402
+ * the host's judgements; here, the pure adapter refuses a surface it cannot honour at
403
+ * all. A live read needs something that outlives a request and can be woken when an
404
+ * event lands. On Cloudflare that is the scope's own Durable Object, which the runtime
405
+ * keeps addressable between requests. This host is a function call inside somebody
406
+ * else's process: nothing here survives the return, so the only honest answer is that
407
+ * there is no subscription to hand out.
408
+ *
409
+ * `never` rather than an omission, and rather than a `subscribe` that throws, because
410
+ * those two fail in the places this one does not: an omission makes
411
+ * `host.liveReads?.subscribe(…)` compile and silently do nothing, and a thrower moves
412
+ * the discovery to runtime, in production, on the one path nobody drove in a test.
413
+ * The field is declared so that reaching for it is refused by the compiler at the call
414
+ * site — the same argument the clock seam makes in its own comment.
415
+ *
416
+ * What this costs, stated rather than hidden: nothing in the fan-out is held to a
417
+ * CONTRACT suite, because a contract suite asserts the two adapters agree and on this
418
+ * they deliberately do not. The tests live on `adapter-cloudflare` instead, beside the
419
+ * only host that can run them. The day a pure host grows a long-lived transport, this
420
+ * comment and those tests' home are the whole change.
421
+ */
422
+ liveReads;
353
423
  dir;
354
424
  checker;
355
425
  directory;
356
426
  scopes = new Map();
357
427
  scopesById = new Map();
428
+ /** The runtimes holding an open `reader` (#1624), least recently read first — `READER_CAP`'s LRU. */
429
+ readers = new Map();
358
430
  /** Per-tenant relational stores (#301), opened lazily and cached by `ref` (the bare
359
431
  * `.sqlite` filename). The pure-adapter analogue of a per-tenant D1 — one file per
360
432
  * (tenant, vertical, binding), physically separate from the scope DBs. */
@@ -399,6 +471,13 @@ export class SqliteScopeHost {
399
471
  roles = new Map(); // 'tenantId/roleKey'
400
472
  /** Executor id → {eventType, handler} (K-22 §4.2). Host code, not module code. */
401
473
  executors = new Map();
474
+ /**
475
+ * `<moduleId>/<job>` → the pass body and its default step policy (#1577). Host
476
+ * code like `executors`, and keyed the way a run row is: the coalescing key's
477
+ * first two thirds, so a run picked off the table finds its handler by the
478
+ * columns it already carries.
479
+ */
480
+ jobs = new Map();
402
481
  /**
403
482
  * The event currently being effected by an executor, stamped onto any admin rows
404
483
  * it writes. Ambient rather than threaded through every HostAdmin signature: it is
@@ -979,7 +1058,9 @@ export class SqliteScopeHost {
979
1058
  at TEXT NOT NULL
980
1059
  );
981
1060
  CREATE INDEX IF NOT EXISTS _substrat_sweep_runs_unit ON _substrat_sweep_runs (kind, unit, id);
982
- CREATE UNIQUE INDEX IF NOT EXISTS _substrat_sweep_runs_intent ON _substrat_sweep_runs (request_id, unit);
1061
+ -- (request_id, kind, unit) since #1572; a directory holding the older
1062
+ -- (request_id, unit) is rebuilt by ensureSweepRunsIntentKind, not by this line.
1063
+ ${SWEEP_RUNS_INTENT_INDEX}
983
1064
  CREATE INDEX IF NOT EXISTS _substrat_sweep_runs_tenant ON _substrat_sweep_runs (tenant_id, id);
984
1065
  CREATE INDEX IF NOT EXISTS _substrat_sweep_runs_at ON _substrat_sweep_runs (at);
985
1066
  -- Model usage (#1054, meter 3): one line per model call a vertical made through
@@ -1367,21 +1448,29 @@ export class SqliteScopeHost {
1367
1448
  await this.applyPendingMigrations(rt);
1368
1449
  // Project each registered module's SCHEDULE grants (#383): a system principal
1369
1450
  // holds exactly the permissions its schedules declared, on this scope. This is
1370
- // what makes `ctx.check` resolve for scheduled work — the gate stays the check,
1371
- // and revoking the tuple (console) is how scheduling is turned off per scope.
1372
- // Idempotent (INSERT OR REPLACE), so a re-provision re-asserts the same grants.
1373
- for (const mod of this.modules.values()) {
1374
- const perms = new Set();
1375
- for (const s of mod.schedules)
1376
- for (const p of s.permissions)
1377
- perms.add(p);
1378
- for (const perm of perms) {
1379
- rt.db
1380
- .prepare(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at)
1381
- VALUES (?, ?, ?, NULL)`)
1382
- .run(`system:${mod.id}`, `granted:${perm}`, `scope:${input.scopeId}`);
1451
+ // what makes `ctx.check` resolve for scheduled work — the gate stays the check.
1452
+ // Idempotent, so a re-provision re-asserts the same grants — SEATED (#1659), with the
1453
+ // statement the Cloudflare adapter seats with: a missing grant is recreated, a revoked
1454
+ // one stays revoked. Turning a scope's schedules off is `revokeFromSystem` (#1666), and
1455
+ // its OFF marker is not a grant, so no re-provision can seat it away; `restoreToSystem`
1456
+ // is the only way back (`grantToSystem` clears a tuple's tombstone, not the switch).
1457
+ //
1458
+ // One turn on the scope actor for the whole loop (#1678): a seat issued while an
1459
+ // invoke held its transaction open joined it, and a rollback un-seated the grant
1460
+ // after provisioning had returned. The switch check lives inside the seat statement.
1461
+ await rt.actor.turn(() => {
1462
+ for (const mod of this.modules.values()) {
1463
+ const perms = new Set();
1464
+ for (const s of mod.schedules)
1465
+ for (const p of s.permissions)
1466
+ perms.add(p);
1467
+ for (const perm of perms) {
1468
+ // A module switched off (#1666) gets nothing seated — see `seatScopeTuple`.
1469
+ const seat = seatScopeTuple(`system:${mod.id}`, `granted:${perm}`, `scope:${input.scopeId}`, null);
1470
+ rt.db.prepare(seat.sql).run(...seat.params);
1471
+ }
1383
1472
  }
1384
- }
1473
+ });
1385
1474
  // Audit a real provision only; an idempotent re-provision changed nothing.
1386
1475
  if (!existing) {
1387
1476
  this.recordAdmin(actor, 'provisionScope', { tenantId: input.tenantId, scopeId: input.scopeId, vertical: record.vertical }, null, record);
@@ -1627,12 +1716,16 @@ export class SqliteScopeHost {
1627
1716
  }
1628
1717
  catch (err) {
1629
1718
  rt.db.exec('ROLLBACK');
1719
+ // #1525: null. This task is the attachment RPC's own — no invoke set an id on
1720
+ // the runtime, and the actor guarantees no other task is mid-flight holding one.
1630
1721
  if (err instanceof PermissionDenied)
1631
- this.recordDenial(rt, subject, operation, err);
1722
+ this.recordDenial(rt, subject, operation, err, null);
1632
1723
  throw err;
1633
1724
  }
1634
- await this.dispatch(rt);
1635
- await this.dispatchExecutors(rt);
1725
+ // #1525: null, for the reason the denial two lines up records null — this task
1726
+ // is the attachment RPC's own and no invoke set an id on the runtime.
1727
+ await this.dispatch(rt, null);
1728
+ await this.dispatchExecutors(rt, null);
1636
1729
  return result;
1637
1730
  });
1638
1731
  /**
@@ -1653,8 +1746,12 @@ export class SqliteScopeHost {
1653
1746
  return await fn(this.operationContext(rt, subject, undefined, undefined, undefined, operation));
1654
1747
  }
1655
1748
  catch (err) {
1749
+ // #1525: the ENCLOSING call's id, deliberately. This runner takes no turn of its
1750
+ // own — it is the connector's dispatch-time read, already inside that call's
1751
+ // actor task — so its refusal belongs to that call, for the reason a consumer's
1752
+ // emit does (#1237): dispatch runs in the same post-commit tail and IS the call.
1656
1753
  if (err instanceof PermissionDenied)
1657
- this.recordDenial(rt, subject, operation, err);
1754
+ this.recordDenial(rt, subject, operation, err, rt.invocationId);
1658
1755
  throw err;
1659
1756
  }
1660
1757
  };
@@ -1780,12 +1877,12 @@ export class SqliteScopeHost {
1780
1877
  forkedFrom: input.forkedFrom ?? dump.scopeId,
1781
1878
  forkedAt: input.forkedAt ?? dump.capturedAt,
1782
1879
  });
1783
- this.loadDump(input.tenantId, input.scopeId, dump.tables);
1880
+ await this.loadDump(input.tenantId, input.scopeId, dump.tables);
1784
1881
  await this.admin.activateScope(actor, input.tenantId, input.scopeId);
1785
1882
  this.recordAdmin(actor, 'importScope', { tenantId: input.tenantId, scopeId: input.scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
1786
1883
  }
1787
1884
  /** Drop-then-replay a dump into a scope's db, refreshing the migration frontier. */
1788
- loadDump(tenantId, scopeId, tables) {
1885
+ async loadDump(tenantId, scopeId, tables) {
1789
1886
  const rt = this.runtime(tenantId, scopeId);
1790
1887
  const db = rt.db;
1791
1888
  const load = db.transaction((dumped) => {
@@ -1869,20 +1966,25 @@ export class SqliteScopeHost {
1869
1966
  db.prepare(`UPDATE OR REPLACE _substrat_tuples SET object = ?
1870
1967
  WHERE object LIKE 'scope:%' AND object <> ?`).run(`scope:${scopeId}`, `scope:${scopeId}`);
1871
1968
  });
1872
- load(tables);
1873
- // The frontier came in with the dump — refresh the cached applied-migration set so
1874
- // a later bind/migrate builds on the loaded state, not the previous one.
1875
- rt.appliedMigrations.clear();
1876
- for (const r of db.prepare('SELECT module_id, version FROM _substrat_migrations').all()) {
1877
- rt.appliedMigrations.add(`${r.module_id}@${r.version}`);
1878
- }
1969
+ // On the scope actor (#1678): issued while an invoke held its transaction open, the
1970
+ // load was a SAVEPOINT inside it, and that invoke's rollback undid the restore after
1971
+ // this verb had audited it.
1972
+ await rt.actor.turn(() => {
1973
+ load(tables);
1974
+ // The frontier came in with the dump — refresh the cached applied-migration set so
1975
+ // a later bind/migrate builds on the loaded state, not the previous one.
1976
+ rt.appliedMigrations.clear();
1977
+ for (const r of db.prepare('SELECT module_id, version FROM _substrat_migrations').all()) {
1978
+ rt.appliedMigrations.add(`${r.module_id}@${r.version}`);
1979
+ }
1980
+ });
1879
1981
  }
1880
1982
  async restoreScope(actor, tenantId, scopeId, dump) {
1881
1983
  // Restore never creates a scope (that is importScope) — an unknown target fails closed.
1882
1984
  const existing = await this.admin.getScopeRecord(actor, tenantId, scopeId);
1883
1985
  if (!existing)
1884
1986
  throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
1885
- this.loadDump(tenantId, scopeId, dump.tables);
1987
+ await this.loadDump(tenantId, scopeId, dump.tables);
1886
1988
  this.recordAdmin(actor, 'restoreScope', { tenantId, scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
1887
1989
  }
1888
1990
  async snapshotScope(actor, tenantId, scopeId, opts) {
@@ -1919,13 +2021,9 @@ export class SqliteScopeHost {
1919
2021
  `archive a primary scope instead`);
1920
2022
  }
1921
2023
  // Close and evict the runtime handle before touching the file.
1922
- const key = `${tenantId}/${scopeId}`;
1923
- const rt = this.scopes.get(key);
1924
- if (rt) {
1925
- rt.db.close();
1926
- this.scopes.delete(key);
1927
- this.scopesById.delete(scopeId);
1928
- }
2024
+ const rt = this.scopes.get(`${tenantId}/${scopeId}`);
2025
+ if (rt)
2026
+ this.closeRuntime(rt);
1929
2027
  // Order is the retry story: hostnames first (a reaped preview URL must stop
1930
2028
  // resolving), then the STORAGE, then the directory row — so a crash mid-way leaves
1931
2029
  // a visible row over empty storage (re-running deleteSnapshot converges), never
@@ -2103,6 +2201,82 @@ export class SqliteScopeHost {
2103
2201
  await this.applyPendingMigrations(rt);
2104
2202
  return this.buildStub(tenantId, scopeId, rt, { kind: 'system', id: moduleId });
2105
2203
  }
2204
+ /**
2205
+ * The lifecycle gate the capability doors share (#1672): the scope must exist in this
2206
+ * tenant, the tenant must be active, the scope must be active — K-3's fail-closed pair
2207
+ * check, worded as the principal door words it, so a scope in another tenant reads the
2208
+ * same as one that does not exist. Migrations are applied as on every door.
2209
+ */
2210
+ async openActiveScope(tenantId, scopeId) {
2211
+ const scope = this.directory
2212
+ .prepare('SELECT tenant_id, status FROM scopes WHERE scope_id = ?')
2213
+ .get(scopeId);
2214
+ if (!scope || scope.tenant_id !== tenantId) {
2215
+ throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
2216
+ }
2217
+ const tenant = this.directory
2218
+ .prepare('SELECT status FROM tenants WHERE tenant_id = ?')
2219
+ .get(tenantId);
2220
+ if (!tenant || tenant.status !== 'active') {
2221
+ throw new Error(`tenant not active (status: ${tenant?.status ?? 'missing'}): ${tenantId}`);
2222
+ }
2223
+ if (scope.status !== 'active') {
2224
+ throw new Error(`scope not active (status: ${scope.status}): ${scopeId}`);
2225
+ }
2226
+ const rt = this.runtime(tenantId, scopeId);
2227
+ await this.applyPendingMigrations(rt);
2228
+ return rt;
2229
+ }
2230
+ /**
2231
+ * The exchange (#1672) — a secret traded for a session, or for the principal a `become`
2232
+ * capability yields. One serialized scope task and one transaction, so the use it takes
2233
+ * and the `capability.exercised` event recording it commit together or not at all; the
2234
+ * event's consumers then dispatch as an invoke's would.
2235
+ */
2236
+ async exchangeCapability(tenantId, scopeId, secret, options) {
2237
+ const rt = await this.openActiveScope(tenantId, scopeId);
2238
+ return rt.actor.enqueue(async () => {
2239
+ // ONE instant for the whole exchange: the row's `last_used_at`, the session's times and
2240
+ // the event's `occurredAt` are one fact, and two clock reads could disagree about it.
2241
+ const now = this.clock();
2242
+ rt.db.exec('BEGIN IMMEDIATE');
2243
+ let outcome;
2244
+ try {
2245
+ outcome = await exchangeCapabilitySecret({
2246
+ sql: spineSql(rt.db),
2247
+ now,
2248
+ // Stamped `{ capability }` by the ordinary emit path, under the exchange's own
2249
+ // pseudo-operation name — the actor is the capability, as on every event it
2250
+ // goes on to cause.
2251
+ emit: (capability, event) => this.operationContext(rt, { kind: 'capability', id: capability }, undefined, undefined, undefined, CAPABILITY_EXCHANGE_OPERATION, [], now).emit(event),
2252
+ }, secret, options?.mode);
2253
+ rt.db.exec('COMMIT');
2254
+ }
2255
+ catch (err) {
2256
+ rt.db.exec('ROLLBACK');
2257
+ throw err;
2258
+ }
2259
+ if (outcome) {
2260
+ await this.dispatch(rt, null);
2261
+ await this.dispatchExecutors(rt, null);
2262
+ }
2263
+ return outcome;
2264
+ });
2265
+ }
2266
+ /**
2267
+ * The capability door (#1672) — mirror of the connection and system doors. The token is
2268
+ * shape-checked and hashed HERE, and only its hash travels on: `buildStub` re-resolves it
2269
+ * to its capability on every invoke, so a revoke or an expiry refuses the very next call
2270
+ * on a stub minted before it.
2271
+ */
2272
+ async getCapabilityScope(sessionToken, tenantId, scopeId, options) {
2273
+ if (!plausibleSessionToken(sessionToken)) {
2274
+ throw substratError('unauthenticated', 'not a capability session token');
2275
+ }
2276
+ const rt = await this.openActiveScope(tenantId, scopeId);
2277
+ const hash = await capabilityTokenHash(sessionToken);
2278
+ return this.buildStub(tenantId, scopeId, rt, { kind: 'capability-session', hash }, options);
2279
+ }
2106
2280
  registeredSchedules() {
2107
2281
  const out = [];
2108
2282
  for (const mod of this.modules.values()) {
@@ -2148,22 +2322,11 @@ export class SqliteScopeHost {
2148
2322
  return report;
2149
2323
  const rt = this.runtime(tenantId, scopeId);
2150
2324
  await this.applyPendingMigrations(rt);
2151
- // Same lazy create as runDueSchedules — a freshness-only module must not hit
2152
- // `no such table` on a scope no schedule ever swept. Despite the table's name it
2153
- // holds two kinds of row (#1232): schedule operations keyed `module/verb`, and
2154
- // the rows written just below, keyed `freshness:<eventType>`. For those,
2155
- // `last_run_at`/`last_status` are the last RECORDED at and verdict — nothing ran.
2156
- // Only half the no-collision claim is enforced: an event type has passed
2157
- // contracts' `eventType` regex (lowercase `ns.verb`, no colon), so a freshness
2158
- // key can never look like an operation — but `scheduleSpec.operation` is
2159
- // `z.string().min(1)`, so a schedule literally named `freshness:orders.placed`
2160
- // would share this row and nothing would refuse it. #1288 tracks the `kind`
2161
- // column that would make the distinction real instead of conventional.
2162
- rt.db.exec(`CREATE TABLE IF NOT EXISTS _substrat_schedule_state (
2163
- schedule_op TEXT PRIMARY KEY,
2164
- last_run_at TEXT,
2165
- last_status TEXT
2166
- )`);
2325
+ // The gating-state table is KERNEL_DDL's since #1288 — `runtime()` above ran it,
2326
+ // so a freshness-only module on a scope no schedule ever swept finds the table
2327
+ // there rather than creating it here. The rows written below are the evaluator's,
2328
+ // `kind = 'freshness'`, keyed `freshness:<eventType>`: for those `last_run_at` /
2329
+ // `last_status` are the last RECORDED at and verdict — nothing ran.
2167
2330
  // Collapse duplicate event types to the tightest window, ACROSS modules.
2168
2331
  const windows = new Map();
2169
2332
  for (const m of this.modules.values()) {
@@ -2175,41 +2338,51 @@ export class SqliteScopeHost {
2175
2338
  }
2176
2339
  if (windows.size === 0)
2177
2340
  return report;
2178
- const types = [...windows.keys()];
2179
- const observed = new Map();
2180
- for (const row of rt.db
2181
- .prepare(`SELECT type, MAX(occurred_at) AS at FROM _substrat_outbox
2182
- WHERE type IN (${types.map(() => '?').join(', ')}) GROUP BY type`)
2183
- .all(...types)) {
2184
- if (row.at !== null)
2185
- observed.set(row.type, row.at);
2186
- }
2187
- const nowIso = this.clock();
2188
- const now = Date.parse(nowIso);
2189
- for (const [eventType, withinHours] of windows) {
2190
- const observedAt = observed.get(eventType) ?? null;
2191
- const outcome = observedAt === null
2192
- ? 'skipped' // never observed — the never-run analogue, not a failure
2193
- : now - Date.parse(observedAt) <= withinHours * 3_600_000
2194
- ? 'ok'
2195
- : 'failed';
2196
- const stateKey = `freshness:${eventType}`;
2197
- const state = rt.db
2198
- .prepare('SELECT last_run_at, last_status FROM _substrat_schedule_state WHERE schedule_op = ?')
2199
- .get(stateKey);
2200
- const changed = state?.last_status !== outcome;
2201
- const heartbeatDue = !state?.last_run_at ||
2202
- now - Date.parse(state.last_run_at) > FRESHNESS_HEARTBEAT_MINUTES * 60_000;
2203
- if (!changed && !heartbeatDue)
2204
- continue;
2205
- rt.db
2206
- .prepare(`INSERT INTO _substrat_schedule_state (schedule_op, last_run_at, last_status)
2207
- VALUES (?, ?, ?)
2208
- ON CONFLICT(schedule_op) DO UPDATE SET last_run_at = excluded.last_run_at,
2209
- last_status = excluded.last_status`)
2210
- .run(stateKey, nowIso, outcome);
2211
- report.checks.push({ eventType, outcome, observedAt, withinHours });
2212
- }
2341
+ // The WHOLE evaluation is one turn on the scope actor (#1678 review): the outbox probe,
2342
+ // the previous verdict and the write that records the new one. Read outside the turn, the
2343
+ // probe saw a suspended operation's UNCOMMITTED event on this shared connection, then
2344
+ // waited here and persisted an `ok` for an event its rollback was about to remove.
2345
+ await rt.actor.turn(() => {
2346
+ const types = [...windows.keys()];
2347
+ const observed = new Map();
2348
+ for (const row of rt.db
2349
+ .prepare(`SELECT type, MAX(occurred_at) AS at FROM _substrat_outbox
2350
+ WHERE type IN (${types.map(() => '?').join(', ')}) GROUP BY type`)
2351
+ .all(...types)) {
2352
+ if (row.at !== null)
2353
+ observed.set(row.type, row.at);
2354
+ }
2355
+ const nowIso = this.clock();
2356
+ const now = Date.parse(nowIso);
2357
+ for (const [eventType, withinHours] of windows) {
2358
+ const observedAt = observed.get(eventType) ?? null;
2359
+ const outcome = observedAt === null
2360
+ ? 'skipped' // never observed — the never-run analogue, not a failure
2361
+ : now - Date.parse(observedAt) <= withinHours * 3_600_000
2362
+ ? 'ok'
2363
+ : 'failed';
2364
+ const stateKey = `freshness:${eventType}`;
2365
+ // #1288: the evaluator's own row, named by kind. Before the column, a module
2366
+ // declaring a schedule called `freshness:<this type>` shared it — and the
2367
+ // verdict read back here was whichever of the two wrote last.
2368
+ const state = rt.db
2369
+ .prepare(`SELECT last_run_at, last_status FROM _substrat_schedule_state
2370
+ WHERE kind = 'freshness' AND schedule_op = ?`)
2371
+ .get(stateKey);
2372
+ const changed = state?.last_status !== outcome;
2373
+ const heartbeatDue = !state?.last_run_at ||
2374
+ now - Date.parse(state.last_run_at) > FRESHNESS_HEARTBEAT_MINUTES * 60_000;
2375
+ if (!changed && !heartbeatDue)
2376
+ continue;
2377
+ rt.db
2378
+ .prepare(`INSERT INTO _substrat_schedule_state (kind, schedule_op, last_run_at, last_status)
2379
+ VALUES ('freshness', ?, ?, ?)
2380
+ ON CONFLICT(kind, schedule_op) DO UPDATE SET last_run_at = excluded.last_run_at,
2381
+ last_status = excluded.last_status`)
2382
+ .run(stateKey, nowIso, outcome);
2383
+ report.checks.push({ eventType, outcome, observedAt, withinHours });
2384
+ }
2385
+ });
2213
2386
  return report;
2214
2387
  }
2215
2388
  async runDueSchedules(moduleId, tenantId, scopeId) {
@@ -2228,35 +2401,58 @@ export class SqliteScopeHost {
2228
2401
  return report;
2229
2402
  const rt = this.runtime(tenantId, scopeId);
2230
2403
  await this.applyPendingMigrations(rt);
2231
- // The grant IS the switch (#383): a scope runs a module's schedules only while it
2232
- // holds a live `system:<moduleId>` grant. This is what makes a foreign-vertical
2233
- // scope (one this module was never provisioned on) a quiet no-op, and what makes
2234
- // "disable scheduling for this tenant" a plain grant revoke — no error, no run.
2404
+ // The grant IS the switch (#383), and the kill switch is its lever (#1666): the
2405
+ // kernel's `systemScheduleState`, the one predicate both adapters run. A scope that
2406
+ // never held the module's grant (a foreign vertical's) is a quiet no-op, exactly as
2407
+ // before. A scope switched OFF reports every schedule `skipped`, never `failed`, and
2408
+ // does not touch its cadence rows — so a restore fires a due schedule on the next pass.
2235
2409
  const nowIso = this.clock();
2236
- const hasGrant = rt.db
2237
- .prepare(`SELECT 1 FROM _substrat_tuples
2238
- WHERE subject = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)
2239
- LIMIT 1`)
2240
- .get(`system:${moduleId}`, nowIso);
2241
- if (!hasGrant)
2410
+ // The whole gate — the switch state and every cadence row — is ONE turn, on the write
2411
+ // connection (#1624). Outside a turn it saw a suspended operation's uncommitted grant,
2412
+ // revoke or cadence row, and fired (or skipped) on a state its rollback was about to
2413
+ // undo. Not the read connection either: these reads decide a write (a fire, then its
2414
+ // cadence row), and a snapshot read ahead of the write's turn is the split #1678 closed.
2415
+ // One turn rather than one per read, because the first turn already waits out any
2416
+ // stranger; nothing but this loop writes a `kind = 'schedule'` row, so reading them all
2417
+ // up front answers what reading each just before its fire did.
2418
+ //
2419
+ // #1288: each schedule's own row. `kind` is what makes that true even when the operation
2420
+ // is spelled `freshness:<eventType>` — without it, the cadence gate read the evaluator's
2421
+ // last recorded verdict as a run of this operation.
2422
+ const gate = await rt.actor.turn(() => {
2423
+ const switched = systemScheduleState(switchSqlOf(rt.db), moduleId, nowIso);
2424
+ const lastRuns = new Map();
2425
+ if (switched !== 'on')
2426
+ return { state: switched, lastRuns };
2427
+ const read = rt.db.prepare(`SELECT last_run_at FROM _substrat_schedule_state
2428
+ WHERE kind = 'schedule' AND schedule_op = ?`);
2429
+ for (const schedule of mod.schedules) {
2430
+ const row = read.get(schedule.operation);
2431
+ lastRuns.set(schedule.operation, row?.last_run_at ?? null);
2432
+ }
2433
+ return { state: switched, lastRuns };
2434
+ });
2435
+ const state = gate.state;
2436
+ if (state === 'ungranted')
2437
+ return report;
2438
+ if (state === 'off') {
2439
+ for (const schedule of mod.schedules) {
2440
+ report.skipped += 1;
2441
+ report.runs.push({ operation: schedule.operation, outcome: 'skipped' });
2442
+ }
2443
+ report.switchedOff = true;
2242
2444
  return report;
2243
- // The spine state table is created lazily on first sweep of a scope — it is
2244
- // kernel-owned (`_substrat_*`), never in a module migration. It holds this
2245
- // loop's rows, keyed by the schedule operation (`module/verb`, where
2246
- // `last_run_at`/`last_status` are when it RAN and how it ended), and — since
2247
- // #1232 — the freshness evaluator's, keyed `freshness:<eventType>`. See
2248
- // checkFreshness for what the columns mean there; #1288 tracks the widening.
2249
- rt.db.exec(`CREATE TABLE IF NOT EXISTS _substrat_schedule_state (
2250
- schedule_op TEXT PRIMARY KEY,
2251
- last_run_at TEXT,
2252
- last_status TEXT
2253
- )`);
2445
+ }
2446
+ // The spine state table is KERNEL_DDL's (#1288) — kernel-owned (`_substrat_*`),
2447
+ // never a module migration, and created by `runtime()` above rather than lazily
2448
+ // here. This loop's rows are `kind = 'schedule'`, keyed by the operation
2449
+ // (`module/verb`, where `last_run_at`/`last_status` are when it RAN and how it
2450
+ // ended); the freshness evaluator's are `kind = 'freshness'` — see checkFreshness
2451
+ // for what the same two columns mean there.
2254
2452
  const now = Date.parse(nowIso);
2255
2453
  for (const schedule of mod.schedules) {
2256
- const row = rt.db
2257
- .prepare('SELECT last_run_at FROM _substrat_schedule_state WHERE schedule_op = ?')
2258
- .get(schedule.operation);
2259
- const lastRun = row?.last_run_at ? Date.parse(row.last_run_at) : null;
2454
+ const lastRunAt = gate.lastRuns.get(schedule.operation) ?? null;
2455
+ const lastRun = lastRunAt ? Date.parse(lastRunAt) : null;
2260
2456
  const dueAt = lastRun === null ? -Infinity : lastRun + schedule.cadence.everyMinutes * 60_000;
2261
2457
  if (now < dueAt) {
2262
2458
  report.skipped += 1;
@@ -2279,18 +2475,27 @@ export class SqliteScopeHost {
2279
2475
  error: err instanceof Error ? err.message : String(err),
2280
2476
  });
2281
2477
  }
2282
- rt.db
2283
- .prepare(`INSERT INTO _substrat_schedule_state (schedule_op, last_run_at, last_status)
2284
- VALUES (?, ?, ?)
2285
- ON CONFLICT(schedule_op) DO UPDATE SET last_run_at = excluded.last_run_at,
2286
- last_status = excluded.last_status`)
2287
- .run(schedule.operation, new Date(now).toISOString(), status);
2478
+ // On the scope actor (#1678): the cadence row joined whatever transaction an invoke
2479
+ // held open, so its rollback forgot the run and the next pass fired it AGAIN.
2480
+ await rt.actor.turn(() => rt.db
2481
+ .prepare(`INSERT INTO _substrat_schedule_state (kind, schedule_op, last_run_at, last_status)
2482
+ VALUES ('schedule', ?, ?, ?)
2483
+ ON CONFLICT(kind, schedule_op) DO UPDATE SET last_run_at = excluded.last_run_at,
2484
+ last_status = excluded.last_status`)
2485
+ .run(schedule.operation, new Date(now).toISOString(), status));
2288
2486
  report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
2289
2487
  }
2290
2488
  return report;
2291
2489
  }
2292
- /** The stub body, shared by the principal, connection, system and impersonation doors. */
2293
- buildStub(tenantId, scopeId, rt, subject, options,
2490
+ /** The stub body, shared by the principal, connection, system, impersonation and capability doors. */
2491
+ buildStub(tenantId, scopeId, rt,
2492
+ /**
2493
+ * Who the stub acts as — or, for the capability door (#1672), the SESSION it holds,
2494
+ * which every invoke re-resolves to its capability inside the scope's actor task. A
2495
+ * capability resolved once at mint time would outlive its own revocation for exactly
2496
+ * the one caller holding the stub.
2497
+ */
2498
+ authority, options,
2294
2499
  /**
2295
2500
  * K-42: the session this stub acts under. `subject` is already the
2296
2501
  * IMPERSONATED principal — that is what makes the permission model answer
@@ -2372,9 +2577,22 @@ export class SqliteScopeHost {
2372
2577
  // which is the leak `causedBy` had to be moved off the host to avoid.
2373
2578
  rt.invocationId = invokeOptions?.invocationId ?? null;
2374
2579
  try {
2580
+ // #1672: the capability door's session, re-resolved on EVERY invoke and inside the
2581
+ // actor task — so nothing can revoke between this read and the transaction below.
2582
+ // Refuses a stale session, a revoked or expired capability and an operation off its
2583
+ // allowlist before anything opens; none of those is a K-35 denial (no key checked).
2584
+ const subject = authority.kind === 'capability-session'
2585
+ ? {
2586
+ kind: 'capability',
2587
+ id: resolveCapabilitySession(spineSql(rt.db), authority.hash, this.clock(), operation),
2588
+ }
2589
+ : authority;
2590
+ // #1672: the secrets this invocation mints — withheld from its idempotency recording,
2591
+ // and what the tripwire on its writes looks for.
2592
+ const minted = [];
2375
2593
  // Fresh per operation: the context carries the K-34 authorization accumulator,
2376
2594
  // which must not leak across operations (invokes are serialized per scope).
2377
- const ctx = this.operationContext(rt, subject, undefined, signals, session, operation);
2595
+ const ctx = this.operationContext(rt, subject, undefined, signals, session, operation, minted);
2378
2596
  const clonedInput = structuredClone(input);
2379
2597
  // #893: parse, don't trust — at the scope door, from the operation's own
2380
2598
  // declaration. BEFORE `BEGIN`, so a malformed call never opens a
@@ -2460,7 +2678,9 @@ export class SqliteScopeHost {
2460
2678
  // that adds a row.
2461
2679
  if (idempotencyKey !== undefined && fingerprint !== undefined) {
2462
2680
  const at = this.clock();
2463
- const record = idempotencyRecordStatement(subject, idempotencyKey, operation, fingerprint, result, committedVersion ?? null, at);
2681
+ const record = idempotencyRecordStatement(subject, idempotencyKey, operation, fingerprint,
2682
+ // #1672: a replay of a mint returns the placeholder, never the secret.
2683
+ redactSecrets(result, minted), committedVersion ?? null, at);
2464
2684
  rt.db.prepare(record.sql).run(...record.params);
2465
2685
  const prune = idempotencyPruneStatement(at);
2466
2686
  rt.db.prepare(prune.sql).run(...prune.params);
@@ -2481,7 +2701,8 @@ export class SqliteScopeHost {
2481
2701
  // K-35: a refused check rolled the operation back. Record it now — a fresh
2482
2702
  // statement in autocommit, AFTER the rollback, so the denial survives it.
2483
2703
  if (err instanceof PermissionDenied)
2484
- this.recordDenial(rt, subject, operation, err, session);
2704
+ // Inside the actor task that set it, so the field is this call's own (#1237).
2705
+ this.recordDenial(rt, subject, operation, err, rt.invocationId, session);
2485
2706
  throw err;
2486
2707
  }
2487
2708
  // Post-commit, still inside the actor task: drain outbox → consumers,
@@ -2492,8 +2713,10 @@ export class SqliteScopeHost {
2492
2713
  // drain — and draining anyway would run consumers as a side effect of a
2493
2714
  // session that may not have side effects.
2494
2715
  if (session?.mode !== 'read-only') {
2495
- await this.dispatch(rt);
2496
- await this.dispatchExecutors(rt);
2716
+ // #1525: still inside the actor task that set it, so these deliveries are
2717
+ // this call's own work — the same tail its consumers' emits are stamped in.
2718
+ await this.dispatch(rt, rt.invocationId);
2719
+ await this.dispatchExecutors(rt, rt.invocationId);
2497
2720
  }
2498
2721
  return structuredClone(result);
2499
2722
  }
@@ -2563,10 +2786,8 @@ export class SqliteScopeHost {
2563
2786
  }
2564
2787
  }
2565
2788
  async close() {
2566
- for (const { db } of this.scopes.values())
2567
- db.close();
2568
- this.scopes.clear();
2569
- this.scopesById.clear();
2789
+ for (const rt of [...this.scopes.values()])
2790
+ this.closeRuntime(rt);
2570
2791
  this.directory.close();
2571
2792
  }
2572
2793
  // -------------------------------------------------------------------------
@@ -2594,7 +2815,15 @@ export class SqliteScopeHost {
2594
2815
  * At-least-once still requires idempotent handlers. Retry is the backstop, not a
2595
2816
  * substitute.
2596
2817
  */
2597
- async dispatchExecutors(rt) {
2818
+ async dispatchExecutors(rt,
2819
+ /**
2820
+ * #1525: the call this drain pass is running in, or null — PASSED, not read off
2821
+ * `rt.invocationId`, for the reason `recordDenial` states. The Cloudflare twin
2822
+ * has no ambient field to read here at all: its executors run on the coordinator
2823
+ * and journal over RPC, so the id has to travel as an argument on that side, and
2824
+ * a recorded fact whose correctness argument differs per adapter drifts.
2825
+ */
2826
+ invocationId) {
2598
2827
  const report = {
2599
2828
  attempted: 0,
2600
2829
  delivered: 0,
@@ -2616,8 +2845,22 @@ export class SqliteScopeHost {
2616
2845
  ORDER BY o.id`)
2617
2846
  .all(deliveryId, executor.eventType, now);
2618
2847
  for (const row of rows) {
2619
- const event = this.parseOutboxRow(row);
2620
2848
  report.attempted += 1;
2849
+ let event;
2850
+ try {
2851
+ event = domainEventOf(row);
2852
+ }
2853
+ catch (err) {
2854
+ // #1636: an event that does not decode is dead-lettered for this executor at
2855
+ // once, and the handler never sees it. The decode sat above this loop's `try`,
2856
+ // so one bad row threw the executor's whole pending list on every pass. Terminal
2857
+ // on the FIRST failure, unlike a handler's: the decode is pure, of text already
2858
+ // in hand, so a retry cannot succeed — and only the decode is caught here, so a
2859
+ // handler's transient failure keeps its backoff below.
2860
+ this.recordExecutorDelivery(rt, row.id, deliveryId, String(err), UNDECODABLE_RETRY, invocationId);
2861
+ report.deadLettered += 1;
2862
+ continue;
2863
+ }
2621
2864
  this.causedBy = event.id;
2622
2865
  try {
2623
2866
  if (executor.kind === 'connector') {
@@ -2628,11 +2871,11 @@ export class SqliteScopeHost {
2628
2871
  else {
2629
2872
  await executor.handler(this.admin, event);
2630
2873
  }
2631
- this.recordExecutorDelivery(rt, row.id, deliveryId, null, executor.retry);
2874
+ this.recordExecutorDelivery(rt, row.id, deliveryId, null, executor.retry, invocationId);
2632
2875
  report.delivered += 1;
2633
2876
  }
2634
2877
  catch (err) {
2635
- const dead = this.recordExecutorDelivery(rt, row.id, deliveryId, err instanceof Error ? (err.stack ?? err.message) : String(err), executor.retry);
2878
+ const dead = this.recordExecutorDelivery(rt, row.id, deliveryId, err instanceof Error ? (err.stack ?? err.message) : String(err), executor.retry, invocationId);
2636
2879
  if (dead)
2637
2880
  report.deadLettered += 1;
2638
2881
  else
@@ -2653,7 +2896,9 @@ export class SqliteScopeHost {
2653
2896
  * silently marking success. Claiming first would make delivery at-most-once and
2654
2897
  * lose an effect on any crash in between.
2655
2898
  */
2656
- recordExecutorDelivery(rt, eventId, deliveryId, error, retry) {
2899
+ recordExecutorDelivery(rt, eventId, deliveryId, error, retry,
2900
+ /** #1525: the call THIS attempt ran in, or null. Last, mirroring the DO's RPC. */
2901
+ invocationId) {
2657
2902
  const prior = rt.db
2658
2903
  .prepare('SELECT attempts FROM _substrat_deliveries WHERE event_id = ? AND consumer_module = ?')
2659
2904
  .get(eventId, deliveryId)?.attempts ?? 0;
@@ -2663,20 +2908,160 @@ export class SqliteScopeHost {
2663
2908
  const nextAttemptAt = error === null || exhausted ? null : backoffAt(attempts, retry, new Date());
2664
2909
  rt.db
2665
2910
  .prepare(`INSERT INTO _substrat_deliveries
2666
- (event_id, consumer_module, delivered_at, error, attempts, next_attempt_at)
2667
- VALUES (?, ?, ?, ?, ?, ?)
2911
+ (event_id, consumer_module, delivered_at, error, attempts, next_attempt_at,
2912
+ invocation_id)
2913
+ VALUES (?, ?, ?, ?, ?, ?, ?)
2668
2914
  ON CONFLICT (event_id, consumer_module) DO UPDATE SET
2669
2915
  delivered_at = excluded.delivered_at,
2670
2916
  error = excluded.error,
2671
2917
  attempts = excluded.attempts,
2672
- next_attempt_at = excluded.next_attempt_at`)
2673
- .run(eventId, deliveryId, new Date().toISOString(), error, attempts, nextAttemptAt);
2918
+ next_attempt_at = excluded.next_attempt_at,
2919
+ -- #1525: overwritten, like the four above. The row describes the LATEST
2920
+ -- attempt, so a retry drained by a different call (or by none) must not
2921
+ -- keep claiming the call that made the first one.
2922
+ invocation_id = excluded.invocation_id`)
2923
+ .run(eventId, deliveryId, new Date().toISOString(), error, attempts, nextAttemptAt,
2924
+ // #1525: the call this attempt ran in, as the caller named it.
2925
+ invocationId);
2674
2926
  return error !== null && exhausted;
2675
2927
  }
2676
2928
  async drainDue(tenantId, scopeId) {
2677
2929
  const rt = this.runtime(tenantId, scopeId);
2678
2930
  await this.applyPendingMigrations(rt);
2679
- return rt.actor.enqueue(() => this.dispatchExecutors(rt));
2931
+ // #1525: null, and honestly so — a sweep is not a call. An attempt this pass makes
2932
+ // records no invocation, which is what distinguishes it from the first attempt the
2933
+ // emitting operation's own tail made.
2934
+ return rt.actor.enqueue(() => this.dispatchExecutors(rt, null));
2935
+ }
2936
+ registerJob(moduleId, name, handler, retry) {
2937
+ const key = `${moduleId}/${name}`;
2938
+ if (this.jobs.has(key))
2939
+ throw new Error(`job '${key}' is already registered`);
2940
+ this.jobs.set(key, { handler, retry });
2941
+ }
2942
+ /**
2943
+ * D-14's in-process driver: the run store is direct SQL on the scope db.
2944
+ *
2945
+ * **The PASS stays outside the actor; every STORE OPERATION takes a turn on it.**
2946
+ * That split is the whole of the concurrency design here, and the first cut got
2947
+ * the second half wrong — it ran these statements on `rt.db` with no turn at all,
2948
+ * under a comment claiming they "interleave exactly as any other write does".
2949
+ * They do not. `invoke` opens a raw `BEGIN IMMEDIATE` on this same connection and
2950
+ * HOLDS IT ACROSS AWAITS (its guards, its handler), so a job-store statement
2951
+ * issued while some unrelated request was mid-operation landed INSIDE that
2952
+ * request's transaction — and rolled back with it. A step whose effect had
2953
+ * already happened would lose its ledger row and run a second time, for no reason
2954
+ * connected to the job at all.
2955
+ *
2956
+ * A turn each is the fix, and it keeps the property the original comment wanted:
2957
+ * a pass may take an hour, and holding the scope's lock for it would stop every
2958
+ * request on that scope, so the pass itself is never enqueued. Only the short
2959
+ * writes are. Nothing nests — a store operation never invokes, and the `invoke`s
2960
+ * a pass makes through `pass.scope()` take their own separate turns — so there is
2961
+ * no path by which this can deadlock against itself.
2962
+ */
2963
+ jobStore(rt) {
2964
+ const db = rt.db;
2965
+ const row = (v) => v ?? null;
2966
+ const findLive = db.prepare(`SELECT * FROM _substrat_job_runs
2967
+ WHERE module_id = ? AND job = ? AND instance = ? AND status = 'running'
2968
+ ORDER BY id DESC LIMIT 1`);
2969
+ const insertRun = db.prepare(`INSERT INTO _substrat_job_runs
2970
+ (id, module_id, job, instance, payload, status, cursor, counters, attempts,
2971
+ last_error, started_at, updated_at, next_attempt_at, ended_at)
2972
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
2973
+ const patchRun = db.prepare(`UPDATE _substrat_job_runs
2974
+ SET status = ?, cursor = ?, counters = ?, attempts = ?, last_error = ?,
2975
+ updated_at = ?, next_attempt_at = ?, ended_at = ?
2976
+ WHERE id = ?`);
2977
+ const patchArgs = (id, p) => [p.status, p.cursor, p.counters, p.attempts, p.lastError, p.updatedAt, p.nextAttemptAt, p.endedAt, id];
2978
+ // `db.transaction` on better-sqlite3 runs its body SYNCHRONOUSLY inside a real
2979
+ // SQLite transaction, so nothing — not another turn of the event loop, not
2980
+ // another caller of this host — can interleave between the read and the write.
2981
+ // That is what makes the two operations below atomic rather than merely ordered.
2982
+ const startOrJoinTx = db.transaction((key, r) => {
2983
+ const live = row(findLive.get(key.moduleId, key.job, key.instance));
2984
+ if (live)
2985
+ return live;
2986
+ insertRun.run(r.id, r.module_id, r.job, r.instance, r.payload, r.status, r.cursor, r.counters, r.attempts, r.last_error, r.started_at, r.updated_at, r.next_attempt_at, r.ended_at);
2987
+ return r;
2988
+ });
2989
+ const commitPassTx = db.transaction((id, p) => {
2990
+ patchRun.run(...patchArgs(id, p));
2991
+ db.prepare('DELETE FROM _substrat_job_steps WHERE run_id = ?').run(id);
2992
+ });
2993
+ // Every store operation takes its own short turn on the scope actor, so no
2994
+ // statement here can land inside an `invoke`'s open transaction. `turn` is the
2995
+ // one place that happens; adding a method without it reintroduces the bug.
2996
+ const turn = (fn) => rt.actor.enqueue(fn);
2997
+ return {
2998
+ startOrJoin: (key, r) => turn(() => startOrJoinTx(key, r)),
2999
+ get: (id) => turn(() => row(db.prepare('SELECT * FROM _substrat_job_runs WHERE id = ?').get(id))),
3000
+ due: (now, limit, afterId) => turn(() => db
3001
+ .prepare(`SELECT * FROM _substrat_job_runs
3002
+ WHERE status = 'running' AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
3003
+ AND (? IS NULL OR id > ?)
3004
+ ORDER BY id LIMIT ?`)
3005
+ .all(now, afterId ?? null, afterId ?? null, limit)),
3006
+ list: (filter) => turn(() => {
3007
+ const where = [];
3008
+ const params = [];
3009
+ for (const [column, value] of [
3010
+ ['module_id', filter.moduleId],
3011
+ ['job', filter.job],
3012
+ ['instance', filter.instance],
3013
+ ['status', filter.status],
3014
+ ]) {
3015
+ if (value !== undefined) {
3016
+ where.push(`${column} = ?`);
3017
+ params.push(value);
3018
+ }
3019
+ }
3020
+ params.push(jobRunListLimit(filter.limit));
3021
+ return db
3022
+ .prepare(`SELECT * FROM _substrat_job_runs
3023
+ ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
3024
+ ORDER BY id DESC LIMIT ?`)
3025
+ .all(...params);
3026
+ }),
3027
+ patch: (id, p) => turn(() => {
3028
+ patchRun.run(...patchArgs(id, p));
3029
+ }),
3030
+ commitPass: (id, p) => turn(() => commitPassTx(id, p)),
3031
+ step: (runId, name) => turn(() => db
3032
+ .prepare('SELECT step, result, attempts, last_error FROM _substrat_job_steps WHERE run_id = ? AND step = ?')
3033
+ .get(runId, name) ?? null),
3034
+ recordStep: (runId, name, result, attempts, lastError, at) => turn(() => {
3035
+ db.prepare(`INSERT INTO _substrat_job_steps (run_id, step, result, attempts, last_error, recorded_at)
3036
+ VALUES (?, ?, ?, ?, ?, ?)
3037
+ ON CONFLICT (run_id, step) DO UPDATE SET result = excluded.result,
3038
+ attempts = excluded.attempts,
3039
+ last_error = excluded.last_error,
3040
+ recorded_at = excluded.recorded_at`).run(runId, name, result, attempts, lastError, at);
3041
+ }),
3042
+ };
3043
+ }
3044
+ async startJobRun(tenantId, scopeId, input) {
3045
+ const rt = this.runtime(tenantId, scopeId);
3046
+ await this.applyPendingMigrations(rt);
3047
+ return jobRunOf(await startJobRun(this.jobStore(rt), input, ulid, this.clock));
3048
+ }
3049
+ async runDueJobs(tenantId, scopeId, options) {
3050
+ const rt = this.runtime(tenantId, scopeId);
3051
+ await this.applyPendingMigrations(rt);
3052
+ return runDueJobRuns({
3053
+ store: this.jobStore(rt),
3054
+ handlerFor: (run) => this.jobs.get(`${run.module_id}/${run.job}`),
3055
+ now: this.clock,
3056
+ openScope: (run) => this.getSystemScope(run.module_id, tenantId, scopeId),
3057
+ maxPasses: options?.maxPasses,
3058
+ limit: options?.limit,
3059
+ });
3060
+ }
3061
+ async jobRuns(tenantId, scopeId, filter) {
3062
+ const rt = this.runtime(tenantId, scopeId);
3063
+ await this.applyPendingMigrations(rt);
3064
+ return (await this.jobStore(rt).list(filter ?? {})).map(jobRunOf);
2680
3065
  }
2681
3066
  async dispatchConnector(tenantId, scopeId, handler, event, options) {
2682
3067
  // The platform half of a routed `connector:<provider>` intent (#574 phase 3): run
@@ -2714,7 +3099,7 @@ export class SqliteScopeHost {
2714
3099
  // "not granted" where the checker answers allow — a read-back that disagrees with
2715
3100
  // enforcement is worse than none, since it is the read an operator would trust.
2716
3101
  const rows = [
2717
- ...rt.db
3102
+ ...this.readDb(rt)
2718
3103
  .prepare(`SELECT subject, relation, expires_at FROM _substrat_tuples
2719
3104
  WHERE subject LIKE 'connection:%' AND relation LIKE 'granted:%'
2720
3105
  AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)`)
@@ -2794,7 +3179,7 @@ export class SqliteScopeHost {
2794
3179
  async executorDeadLetters(tenantId, scopeId) {
2795
3180
  const rt = this.runtime(tenantId, scopeId);
2796
3181
  await this.applyPendingMigrations(rt);
2797
- const rows = rt.db
3182
+ const rows = this.readDb(rt)
2798
3183
  .prepare(`SELECT d.event_id, d.consumer_module, d.attempts, d.error, d.delivered_at, o.type
2799
3184
  FROM _substrat_deliveries d
2800
3185
  JOIN _substrat_outbox o ON o.id = d.event_id
@@ -2815,31 +3200,46 @@ export class SqliteScopeHost {
2815
3200
  async listPlatformRequests(tenantId, scopeId) {
2816
3201
  const rt = this.runtime(tenantId, scopeId);
2817
3202
  await this.applyPendingMigrations(rt);
2818
- const rows = rt.db
3203
+ const rows = this.readDb(rt)
2819
3204
  .prepare(`SELECT ${PLATFORM_REQUEST_COLUMNS}
2820
3205
  FROM _substrat_platform_requests WHERE status = 'pending' ORDER BY id`)
2821
3206
  .all();
2822
- return rows.map(rowToPlatformRequest);
3207
+ // Tolerant (#1588): one undecodable row comes back naming why, never throws for the list.
3208
+ return rows.map(platformRequestOf);
2823
3209
  }
2824
3210
  async listPlatformRequestHistory(tenantId, scopeId, filter) {
2825
3211
  const rt = this.runtime(tenantId, scopeId);
2826
3212
  await this.applyPendingMigrations(rt);
2827
3213
  const q = platformRequestHistoryQuery(filter);
2828
- return rt.db.prepare(q.sql).all(...q.params).map(rowToPlatformRequest);
3214
+ return this.readDb(rt).prepare(q.sql).all(...q.params).map(platformRequestOf);
2829
3215
  }
2830
3216
  async settlePlatformRequest(tenantId, scopeId, id, outcome) {
2831
3217
  const rt = this.runtime(tenantId, scopeId);
2832
3218
  await this.applyPendingMigrations(rt);
2833
3219
  await rt.actor.enqueue(() => {
3220
+ // Compare-and-set on `pending` (#1600 review). The drain reads pending rows, runs a
3221
+ // handler, then settles — and between the read and the settle a subject erasure can
3222
+ // redact the row. Settling by `id` alone let that stale pass overwrite the redaction
3223
+ // and write a provider's reply, which can quote the person, back into `last_error`.
3224
+ // Nothing legitimate is refused by this: `listPlatformRequests` returns only pending
3225
+ // rows, so every settle in the tree targets one that was pending when it was read.
3226
+ // A settle that finds the row already terminal does nothing, deliberately silently —
3227
+ // throwing would make the drain's blanket catch retry a row that is correctly over.
2834
3228
  rt.db
2835
3229
  .prepare(`UPDATE _substrat_platform_requests
2836
3230
  SET status = ?, result = COALESCE(?, result), last_error = ?, last_failure = ?,
2837
3231
  attempts = attempts + 1, settled_at = ?
2838
- WHERE id = ?`)
3232
+ WHERE id = ? AND status = 'pending'`)
2839
3233
  .run(outcome.status, outcome.result === undefined ? null : JSON.stringify(outcome.result), outcome.lastError ?? null, outcome.failure == null ? null : JSON.stringify(outcome.failure), outcome.status === 'pending' ? null : new Date().toISOString(), id);
2840
3234
  });
2841
3235
  }
2842
- async dispatch(rt) {
3236
+ async dispatch(rt,
3237
+ /**
3238
+ * #1525: the call this drain is running in, or null — PASSED, for the reason
3239
+ * `dispatchExecutors` states. Every round of the loop belongs to it: a consumer's
3240
+ * own emit is delivered in the same tail, so the whole cascade is one call's work.
3241
+ */
3242
+ invocationId) {
2843
3243
  for (let round = 0; round < 50; round++) {
2844
3244
  let deliveredAny = false;
2845
3245
  for (const mod of this.modules.values()) {
@@ -2854,7 +3254,24 @@ export class SqliteScopeHost {
2854
3254
  ORDER BY o.id`)
2855
3255
  .all(consumer.eventType, mod.id);
2856
3256
  for (const row of rows) {
2857
- const event = this.parseOutboxRow(row);
3257
+ let event;
3258
+ try {
3259
+ event = domainEventOf(row);
3260
+ }
3261
+ catch (err) {
3262
+ // #1636: dead-letter an event that does not decode, exactly as a failed
3263
+ // handler is — the decode sat ABOVE the `try` below, so one bad row halted
3264
+ // every event of this type behind it, on every pass. The consumer is never
3265
+ // handed it: an event built from stand-ins is not one it may act on. Only
3266
+ // the decode is caught; the journal write is not, so a failure to record
3267
+ // this still surfaces as it would for a handler's dead letter.
3268
+ rt.db
3269
+ .prepare(`INSERT INTO _substrat_deliveries
3270
+ (event_id, consumer_module, delivered_at, error, invocation_id)
3271
+ VALUES (?, ?, ?, ?, ?)`)
3272
+ .run(row.id, mod.id, new Date().toISOString(), String(err), invocationId);
3273
+ continue;
3274
+ }
2858
3275
  const ctx = this.operationContext(rt, asPrincipal(this.systemPrincipal), {
2859
3276
  system: mod.id,
2860
3277
  });
@@ -2867,9 +3284,10 @@ export class SqliteScopeHost {
2867
3284
  try {
2868
3285
  await consumer.handler(ctx, event);
2869
3286
  rt.db
2870
- .prepare(`INSERT INTO _substrat_deliveries (event_id, consumer_module, delivered_at)
2871
- VALUES (?, ?, ?)`)
2872
- .run(event.id, mod.id, new Date().toISOString());
3287
+ .prepare(`INSERT INTO _substrat_deliveries
3288
+ (event_id, consumer_module, delivered_at, invocation_id)
3289
+ VALUES (?, ?, ?, ?)`)
3290
+ .run(event.id, mod.id, new Date().toISOString(), invocationId);
2873
3291
  rt.db.exec('COMMIT');
2874
3292
  deliveredAny = true;
2875
3293
  }
@@ -2878,9 +3296,10 @@ export class SqliteScopeHost {
2878
3296
  // Dead-letter (v0): journal the failure so one poison event
2879
3297
  // can't wedge the loop. Real redelivery/backoff is a later cut.
2880
3298
  rt.db
2881
- .prepare(`INSERT INTO _substrat_deliveries (event_id, consumer_module, delivered_at, error)
2882
- VALUES (?, ?, ?, ?)`)
2883
- .run(event.id, mod.id, new Date().toISOString(), String(err));
3299
+ .prepare(`INSERT INTO _substrat_deliveries
3300
+ (event_id, consumer_module, delivered_at, error, invocation_id)
3301
+ VALUES (?, ?, ?, ?, ?)`)
3302
+ .run(event.id, mod.id, new Date().toISOString(), String(err), invocationId);
2884
3303
  }
2885
3304
  finally {
2886
3305
  // Cleared on BOTH paths. Left set, the id would leak onto every later
@@ -2902,6 +3321,19 @@ export class SqliteScopeHost {
2902
3321
  * point, since the denial is exactly the write the rolled-back operation could not make.
2903
3322
  */
2904
3323
  recordDenial(rt, subject, operation, err,
3324
+ /**
3325
+ * #1525: the invocation this refusal belongs to, or null — PASSED, not read off
3326
+ * `rt.invocationId` here.
3327
+ *
3328
+ * Every denial path on THIS adapter runs inside the scope's actor task, so reading
3329
+ * `rt.invocationId` would in fact be right at each one. It is passed anyway, to keep
3330
+ * one discipline across both adapters: the Cloudflare side has two denial paths that
3331
+ * run outside its queue, where the ambient read rests on an argument about workerd's
3332
+ * input gate rather than on anything local. A recorded fact whose correctness
3333
+ * argument differs per adapter is the kind that drifts, so on both sides the caller
3334
+ * says what it knows.
3335
+ */
3336
+ invocationId,
2905
3337
  /** K-42: the session the refused call ran under, when it ran under one. */
2906
3338
  impersonation) {
2907
3339
  // Only an ENFORCED denial (assertAllowed, which attaches the checked permission +
@@ -2909,39 +3341,15 @@ export class SqliteScopeHost {
2909
3341
  // policy, carries no permission key, and is left to the module.
2910
3342
  if (!err.permission || !err.node)
2911
3343
  return;
2912
- const actor = subject.kind === 'system'
2913
- ? { system: subject.id }
2914
- : subject.kind === 'connection'
2915
- ? { connection: subject.id }
2916
- : subject.id;
3344
+ const actor = actorOf(subject);
2917
3345
  rt.db
2918
3346
  .prepare(`INSERT INTO _substrat_denials
2919
- (id, actor, permission, tenant_id, scope_id, operation, impersonation, at)
2920
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
2921
- .run(ulid(), JSON.stringify(actor), err.permission, err.node.tenantId, err.node.scopeId ?? null, operation, impersonation ? JSON.stringify(impersonationStampOf(impersonation)) : null, new Date().toISOString());
2922
- }
2923
- parseOutboxRow(row) {
2924
- return domainEvent.parse({
2925
- id: row.id,
2926
- type: row.type,
2927
- schemaVersion: row.schema_version,
2928
- occurredAt: row.occurred_at,
2929
- tenantId: row.tenant_id,
2930
- scopeId: row.scope_id,
2931
- actor: JSON.parse(row.actor),
2932
- entity: { entityType: row.entity_type, entityId: row.entity_id },
2933
- piiClass: row.pii_class,
2934
- ...(row.subject_id ? { subjectId: row.subject_id } : {}),
2935
- ...(row.authorization ? { authorization: JSON.parse(row.authorization) } : {}),
2936
- // K-42: the stamp survives the read, so a consumer's event and an executor's
2937
- // are the same fact the stored row is. Absent rather than null when nobody
2938
- // was impersonating, because `DomainEvent.impersonation` is optional — the
2939
- // shape module code never sees is also the shape it cannot branch on.
2940
- ...(row.impersonation ? { impersonation: JSON.parse(row.impersonation) } : {}),
2941
- // #1231: absent rather than null, the same shape rule as the stamp above.
2942
- ...(row.operation ? { operation: row.operation } : {}),
2943
- payload: row.payload === null ? undefined : JSON.parse(row.payload),
2944
- });
3347
+ (id, actor, permission, tenant_id, scope_id, operation, impersonation,
3348
+ invocation_id, at)
3349
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
3350
+ .run(ulid(), JSON.stringify(actor), err.permission, err.node.tenantId, err.node.scopeId ?? null, operation, impersonation ? JSON.stringify(impersonationStampOf(impersonation)) : null,
3351
+ // #1525: the call this refusal belongs to, as the caller named it.
3352
+ invocationId, new Date().toISOString());
2945
3353
  }
2946
3354
  // -------------------------------------------------------------------------
2947
3355
  // Admin surface (enforcement input, §9.2.5)
@@ -3369,6 +3777,9 @@ export class SqliteScopeHost {
3369
3777
  (tenant_id, subject, relation, object, expires_at)
3370
3778
  VALUES (?, ?, ?, ?, ?)`)
3371
3779
  .run(tenantId, subject, relation, object, expiresAt ?? null);
3780
+ // The EXPLICIT grant: `INSERT OR REPLACE` clears a tombstone, because a re-grant must
3781
+ // grant. Provisioning seats with `seatScopeTuple` instead, which never un-revokes
3782
+ // (#1659) — keep the two apart.
3372
3783
  const writeScopeTuple = (node, subject, relation, object, expiresAt) => {
3373
3784
  if (!node.scopeId)
3374
3785
  throw new Error('scope tuple requires node.scopeId');
@@ -3389,6 +3800,82 @@ export class SqliteScopeHost {
3389
3800
  writeTenantTuple(node.tenantId, subject, `granted:${permission}`, `tenant:${node.tenantId}`, expiresAt);
3390
3801
  }
3391
3802
  };
3803
+ /**
3804
+ * A node's write, on the scope actor when the node is a scope (#1678). `invoke` holds a
3805
+ * raw `BEGIN IMMEDIATE` on the scope's connection across awaits, so a bare statement
3806
+ * issued mid-invoke joined that transaction and rolled back with it — after the verb
3807
+ * had answered success and audited. `body` holds the check AND the write, synchronously,
3808
+ * so nothing moves between them. Tenant-level: a directory write, which no scope's
3809
+ * transaction can hold, so it runs as it always did.
3810
+ */
3811
+ const onNode = (node, body) => node.scopeId
3812
+ ? this.runtime(node.tenantId, node.scopeId).actor.turn(body)
3813
+ : Promise.resolve().then(body);
3814
+ /** #1666: move one module's schedule switch on one scope — see `HostAdmin.revokeFromSystem`. */
3815
+ const switchSystem = async (actor, raw, to) => {
3816
+ const input = systemSwitch.parse(raw);
3817
+ const { tenantId, scopeId } = input.node;
3818
+ const scope = this.directory
3819
+ .prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
3820
+ .get(scopeId);
3821
+ if (!scope || scope.tenant_id !== tenantId) {
3822
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3823
+ }
3824
+ // AUDIT FIRST (#1666 review), exactly as the Cloudflare adapter: the intent row before
3825
+ // anything moves, the outcome row after, on every attempt. The directory and the
3826
+ // scope's file are separate databases, so a crash between them leaves an intent with
3827
+ // no recorded outcome — never a moved switch with no audit row.
3828
+ const operationId = ulid();
3829
+ const action = to === 'off' ? 'revokeFromSystem' : 'restoreToSystem';
3830
+ const target = { tenantId, scopeId };
3831
+ const base = { operationId, moduleId: input.moduleId, schedules: to };
3832
+ this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason: input.reason });
3833
+ let outcome;
3834
+ try {
3835
+ // A turn on the scope actor (#1666 review), exactly as the job store (#1577): `invoke`
3836
+ // holds a raw `BEGIN IMMEDIATE` on this same connection across awaits, so a bare
3837
+ // `db.transaction` issued mid-invoke became a SAVEPOINT inside it and rolled back
3838
+ // with it — after this verb had already audited `applied`. `turn` is re-entrant, so a
3839
+ // caller already inside one of this scope's tasks joins it rather than deadlocking.
3840
+ const rt = this.runtime(tenantId, scopeId);
3841
+ outcome = await rt.actor.turn(() => rt.db.transaction(() => switchSystemSchedules(switchSqlOf(rt.db), {
3842
+ moduleId: input.moduleId,
3843
+ scopeId,
3844
+ to,
3845
+ at: new Date().toISOString(),
3846
+ }))());
3847
+ }
3848
+ catch (err) {
3849
+ try {
3850
+ this.recordAdmin(actor, action, target, null, {
3851
+ ...base,
3852
+ phase: 'failed',
3853
+ error: err instanceof Error ? err.message : String(err),
3854
+ });
3855
+ }
3856
+ catch {
3857
+ // Best effort: the original error is what the caller must see.
3858
+ }
3859
+ throw err;
3860
+ }
3861
+ this.recordAdmin(actor, action, target, null, {
3862
+ ...base,
3863
+ phase: outcome.held ? 'applied' : 'refused',
3864
+ changed: outcome.changed,
3865
+ permissions: outcome.permissions,
3866
+ });
3867
+ if (!outcome.held) {
3868
+ throw substratError('not_found', `scope ${scopeId} holds no system grant for module '${input.moduleId}' — nothing to switch ${to} ` +
3869
+ `(check the module id: it is the module's manifest id, e.g. '@substrat-run/engine-absence')`);
3870
+ }
3871
+ return {
3872
+ operationId,
3873
+ moduleId: input.moduleId,
3874
+ schedules: to,
3875
+ changed: outcome.changed,
3876
+ permissions: outcome.permissions,
3877
+ };
3878
+ };
3392
3879
  return {
3393
3880
  // #603: fixed at construction — a host built without a box can never store a
3394
3881
  // credential, and saying so is what lets a transport answer 503 instead of 500.
@@ -3447,7 +3934,8 @@ export class SqliteScopeHost {
3447
3934
  assignRole: async (actor, assignment) => {
3448
3935
  const subject = `principal:${assignment.principalId}`;
3449
3936
  if (assignment.node.scopeId) {
3450
- writeScopeTuple(assignment.node, subject, `role:${assignment.roleKey}`, `scope:${assignment.node.scopeId}`);
3937
+ const scopeNode = assignment.node.scopeId;
3938
+ await onNode(assignment.node, () => writeScopeTuple(assignment.node, subject, `role:${assignment.roleKey}`, `scope:${scopeNode}`));
3451
3939
  }
3452
3940
  else {
3453
3941
  writeTenantTuple(assignment.node.tenantId, subject, `role:${assignment.roleKey}`, `tenant:${assignment.node.tenantId}`);
@@ -3463,11 +3951,14 @@ export class SqliteScopeHost {
3463
3951
  const now = new Date().toISOString();
3464
3952
  let changes;
3465
3953
  if (assignment.node.scopeId) {
3466
- const rt = this.runtime(assignment.node.tenantId, assignment.node.scopeId);
3467
- changes = rt.db
3954
+ // On the scope actor (#1678): a revoke that joined a stranger's transaction and
3955
+ // rolled back with it left the role LIVE after the caller was told it was gone.
3956
+ const scopeNode = assignment.node.scopeId;
3957
+ const rt = this.runtime(assignment.node.tenantId, scopeNode);
3958
+ changes = await rt.actor.turn(() => rt.db
3468
3959
  .prepare(`UPDATE _substrat_tuples SET revoked_at = ?
3469
- WHERE subject = ? AND relation = ? AND object = ? AND revoked_at IS NULL`)
3470
- .run(now, subject, relation, `scope:${assignment.node.scopeId}`).changes;
3960
+ WHERE subject = ? AND relation = ? AND object = ? AND revoked_at IS NULL`)
3961
+ .run(now, subject, relation, `scope:${scopeNode}`).changes);
3471
3962
  }
3472
3963
  else {
3473
3964
  changes = this.directory
@@ -3484,44 +3975,56 @@ export class SqliteScopeHost {
3484
3975
  // trust: the parse is where an `expiresAt` carrying a UTC offset is normalised
3485
3976
  // to Z text (#963), and liveness here is a lexicographic `expires_at > ?`.
3486
3977
  const grant = capabilityGrant.parse(raw);
3487
- writeGrant(`principal:${grant.principalId}`, grant.permission, grant.node, grant.entity, grant.expiresAt);
3978
+ await onNode(grant.node, () => writeGrant(`principal:${grant.principalId}`, grant.permission, grant.node, grant.entity, grant.expiresAt));
3488
3979
  this.recordAdmin(actor, 'grant', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, grant);
3489
3980
  },
3490
3981
  grantToConnection: async (actor, raw) => {
3491
3982
  const grant = connectionGrant.parse(raw);
3492
- const conn = this.connectionRow(grant.connectionId);
3493
- if (conn.revoked_at) {
3494
- throw new Error(`connection ${grant.connectionId} is revoked — grant nothing to it`);
3495
- }
3496
- // The grant may not reach outside what the connection already is. A
3497
- // connection is keyed (tenant, vertical, provider); letting it hold a
3498
- // permission in another tenant would make the key decorative.
3499
- if (conn.tenant_id !== grant.node.tenantId) {
3500
- throw new Error(`connection ${grant.connectionId} belongs to tenant ${conn.tenant_id} and cannot ` +
3501
- `be granted anything in ${grant.node.tenantId}`);
3502
- }
3503
- if (grant.node.scopeId) {
3504
- const scope = this.directory
3505
- .prepare('SELECT tenant_id, vertical FROM scopes WHERE scope_id = ?')
3506
- .get(grant.node.scopeId);
3507
- if (!scope || scope.tenant_id !== grant.node.tenantId) {
3508
- throw new Error(`unknown scope ${grant.node.scopeId} in tenant ${grant.node.tenantId}`);
3983
+ const check = () => {
3984
+ const conn = this.connectionRow(grant.connectionId);
3985
+ if (conn.revoked_at) {
3986
+ throw new Error(`connection ${grant.connectionId} is revoked — grant nothing to it`);
3509
3987
  }
3510
- if (scope.vertical !== conn.vertical) {
3511
- throw new Error(`connection ${grant.connectionId} is for vertical '${conn.vertical}' and scope ` +
3512
- `${grant.node.scopeId} runs '${scope.vertical ?? 'none'}'`);
3988
+ // The grant may not reach outside what the connection already is. A
3989
+ // connection is keyed (tenant, vertical, provider); letting it hold a
3990
+ // permission in another tenant would make the key decorative.
3991
+ if (conn.tenant_id !== grant.node.tenantId) {
3992
+ throw new Error(`connection ${grant.connectionId} belongs to tenant ${conn.tenant_id} and cannot ` +
3993
+ `be granted anything in ${grant.node.tenantId}`);
3513
3994
  }
3514
- }
3515
- writeGrant(subjectRef({ kind: 'connection', id: grant.connectionId }), grant.permission, grant.node, undefined, grant.expiresAt);
3516
- // #592: the directory-side record, alongside the tuple. The tuple is checked
3517
- // where it lives; this row is what provision/reconcile gather FROM, so the
3518
- // grant reaches scopes provisioned after it without a human replaying it.
3519
- this.directory
3520
- .prepare(`INSERT OR REPLACE INTO _substrat_connection_grants
3521
- (connection_id, tenant_id, vertical, permission, scope_id, expires_at,
3522
- granted_by, granted_at, revoked_at)
3523
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`)
3524
- .run(grant.connectionId, grant.node.tenantId, conn.vertical, grant.permission, grant.node.scopeId ?? null, grant.expiresAt ?? null, grant.grantedBy, new Date().toISOString());
3995
+ if (grant.node.scopeId) {
3996
+ const scope = this.directory
3997
+ .prepare('SELECT tenant_id, vertical FROM scopes WHERE scope_id = ?')
3998
+ .get(grant.node.scopeId);
3999
+ if (!scope || scope.tenant_id !== grant.node.tenantId) {
4000
+ throw new Error(`unknown scope ${grant.node.scopeId} in tenant ${grant.node.tenantId}`);
4001
+ }
4002
+ if (scope.vertical !== conn.vertical) {
4003
+ throw new Error(`connection ${grant.connectionId} is for vertical '${conn.vertical}' and scope ` +
4004
+ `${grant.node.scopeId} runs '${scope.vertical ?? 'none'}'`);
4005
+ }
4006
+ }
4007
+ return conn;
4008
+ };
4009
+ // Once BEFORE the turn, so a refused grant never opens (and so creates) the scope's
4010
+ // file; then again inside it, with the tuple and the directory row, in ONE body on
4011
+ // the scope actor (#1678) — so the tuple cannot roll back with a stranger's
4012
+ // transaction and leave the directory row (#592) claiming a grant the scope lacks.
4013
+ check();
4014
+ const conn = await onNode(grant.node, () => {
4015
+ const row = check();
4016
+ writeGrant(subjectRef({ kind: 'connection', id: grant.connectionId }), grant.permission, grant.node, undefined, grant.expiresAt);
4017
+ // #592: the directory-side record, alongside the tuple. The tuple is checked
4018
+ // where it lives; this row is what provision/reconcile gather FROM, so the
4019
+ // grant reaches scopes provisioned after it without a human replaying it.
4020
+ this.directory
4021
+ .prepare(`INSERT OR REPLACE INTO _substrat_connection_grants
4022
+ (connection_id, tenant_id, vertical, permission, scope_id, expires_at,
4023
+ granted_by, granted_at, revoked_at)
4024
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`)
4025
+ .run(grant.connectionId, grant.node.tenantId, row.vertical, grant.permission, grant.node.scopeId ?? null, grant.expiresAt ?? null, grant.grantedBy, new Date().toISOString());
4026
+ return row;
4027
+ });
3525
4028
  this.recordAdmin(actor, 'grantToConnection', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId, vertical: conn.vertical }, null, {
3526
4029
  connectionId: grant.connectionId,
3527
4030
  provider: conn.provider,
@@ -3537,15 +4040,79 @@ export class SqliteScopeHost {
3537
4040
  // principal is bound to the module, and a scope only ever runs the modules
3538
4041
  // its own host registered.
3539
4042
  const grant = systemGrant.parse(raw);
3540
- writeGrant(subjectRef({ kind: 'system', id: grant.moduleId }), grant.permission, grant.node, undefined, grant.expiresAt);
4043
+ const write = () => writeGrant(subjectRef({ kind: 'system', id: grant.moduleId }), grant.permission, grant.node, undefined, grant.expiresAt);
4044
+ const scope = grant.node.scopeId;
4045
+ if (scope) {
4046
+ // #1666: refused while the module is switched off on this scope. The check and the
4047
+ // write share one synchronous body inside one turn on the scope actor, so no switch
4048
+ // can move between them — and (#1666 review) neither can land inside an unrelated
4049
+ // `invoke`'s open transaction and roll back with it after this verb returned. The
4050
+ // turn is re-entrant: from inside one of this scope's tasks it joins that task.
4051
+ // Restore is the lever; a grant is not.
4052
+ const rt = this.runtime(grant.node.tenantId, scope);
4053
+ await rt.actor.turn(() => {
4054
+ if (systemSwitchedOff(switchSqlOf(rt.db), grant.moduleId)) {
4055
+ throw substratError('conflict', systemSwitchedOffMessage(grant.moduleId, scope));
4056
+ }
4057
+ write();
4058
+ });
4059
+ }
4060
+ else {
4061
+ // Tenant-level: a directory write, which no scope's transaction can hold.
4062
+ write();
4063
+ }
3541
4064
  this.recordAdmin(actor, 'grantToSystem', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, { moduleId: grant.moduleId, permission: grant.permission, node: grant.node });
3542
4065
  },
4066
+ // #1666: the schedule kill switch and its lever back — `system-switch.ts` is the
4067
+ // whole rule, shared with the Cloudflare adapter; this is the directory check, the
4068
+ // transaction and the audit row around it.
4069
+ revokeFromSystem: async (actor, raw) => switchSystem(actor, raw, 'off'),
4070
+ restoreToSystem: async (actor, raw) => switchSystem(actor, raw, 'on'),
4071
+ // #1672 — the platform's two capability verbs, both a turn on the scope actor
4072
+ // (#1678: every scope-level admin write takes one, so none lands mid-invoke inside a
4073
+ // stranger's transaction) and both in the admin log without the secret or its hash.
4074
+ mintCapability: async (actor, tenantId, scopeId, input) => {
4075
+ const rt = await this.openActiveScope(tenantId, scopeId);
4076
+ const minted = await rt.actor.turn(() => mintBecomeCapability(spineSql(rt.db), input, actor, this.clock()));
4077
+ this.recordAdmin(actor, 'mintCapability', { tenantId, scopeId }, null, {
4078
+ capabilityId: minted.id,
4079
+ mode: 'become',
4080
+ principal: input.principal,
4081
+ expiresAt: input.expiresAt,
4082
+ maxUses: input.maxUses,
4083
+ label: input.label ?? null,
4084
+ });
4085
+ return minted;
4086
+ },
4087
+ revokeCapability: async (actor, tenantId, scopeId, capabilityId) => {
4088
+ const scope = this.directory
4089
+ .prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
4090
+ .get(scopeId);
4091
+ if (!scope || scope.tenant_id !== tenantId) {
4092
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4093
+ }
4094
+ const rt = this.runtime(tenantId, scopeId);
4095
+ await this.applyPendingMigrations(rt);
4096
+ const before = await rt.actor.turn(() => rt.db.transaction(() => revokeCapabilityAsPlatform(spineSql(rt.db), capabilityId, actor, this.clock()))());
4097
+ if (!before) {
4098
+ throw substratError('not_found', `no capability ${capabilityId} in scope ${scopeId}`);
4099
+ }
4100
+ this.recordAdmin(actor, 'revokeCapability', { tenantId, scopeId }, before, {
4101
+ capabilityId,
4102
+ revoked: true,
4103
+ });
4104
+ },
3543
4105
  grantToOrg: async (actor, orgId, permission, node, entity) => {
3544
4106
  // The org must exist in the node's tenant. A grant to a phantom org is
3545
4107
  // worse than an error: it looks applied, resolves for nobody, and shows up
3546
4108
  // in the permission diff as though access were conferred.
4109
+ // Checked once before the turn, so a refusal opens no scope file; then again with
4110
+ // the write in one body, on the scope actor when the node is a scope (#1678).
3547
4111
  requireOrg(node.tenantId, orgId);
3548
- writeGrant(`org:${orgId}`, permission, node, entity);
4112
+ await onNode(node, () => {
4113
+ requireOrg(node.tenantId, orgId);
4114
+ writeGrant(`org:${orgId}`, permission, node, entity);
4115
+ });
3549
4116
  this.recordAdmin(actor, 'grantToOrg', { tenantId: node.tenantId, scopeId: node.scopeId }, null, { orgId, permission, node, entity });
3550
4117
  },
3551
4118
  // -- vertical + version registry (#31) ---------------------------------
@@ -3787,7 +4354,7 @@ export class SqliteScopeHost {
3787
4354
  const parsed = publishVersionInput.parse(input);
3788
4355
  const owning = readVertical(parsed.verticalSlug);
3789
4356
  if (!owning) {
3790
- throw new Error(`unknown vertical '${parsed.verticalSlug}'`);
4357
+ throw substratError('not_found', `unknown vertical '${parsed.verticalSlug}'`);
3791
4358
  }
3792
4359
  // Lands PENDING — a push is not a deploy — except for a PRIVATE vertical
3793
4360
  // (tenant-owned, not listed), whose blast radius is its own tenant: there the
@@ -3830,7 +4397,7 @@ export class SqliteScopeHost {
3830
4397
  setVerticalListed: async (actor, slug, listed) => {
3831
4398
  const existing = readVertical(slug);
3832
4399
  if (!existing)
3833
- throw new Error(`unknown vertical '${slug}'`);
4400
+ throw substratError('not_found', `unknown vertical '${slug}'`);
3834
4401
  // Listing is the moment other tenants start trusting this code, so the
3835
4402
  // version they would install must carry a real staff vouch — an auto-admitted
3836
4403
  // prod version has never been read by anyone but its author.
@@ -3853,14 +4420,14 @@ export class SqliteScopeHost {
3853
4420
  requestPublish: async (actor, slug) => {
3854
4421
  const existing = readVertical(slug);
3855
4422
  if (!existing)
3856
- throw new Error(`unknown vertical '${slug}'`);
4423
+ throw substratError('not_found', `unknown vertical '${slug}'`);
3857
4424
  this.directory.prepare('UPDATE verticals SET publish_requested_at = ? WHERE slug = ?').run(new Date().toISOString(), slug);
3858
4425
  this.recordAdmin(actor, 'requestPublish', { tenantId: null }, null, { slug });
3859
4426
  },
3860
4427
  setVerticalInstallsBlocked: async (actor, slug, blocked) => {
3861
4428
  const existing = readVertical(slug);
3862
4429
  if (!existing)
3863
- throw new Error(`unknown vertical '${slug}'`);
4430
+ throw substratError('not_found', `unknown vertical '${slug}'`);
3864
4431
  this.directory
3865
4432
  .prepare('UPDATE verticals SET installs_blocked = ? WHERE slug = ?')
3866
4433
  .run(blocked ? 1 : 0, slug);
@@ -3869,7 +4436,7 @@ export class SqliteScopeHost {
3869
4436
  setVerticalTenantProvisioner: async (actor, slug, granted) => {
3870
4437
  const existing = readVertical(slug);
3871
4438
  if (!existing)
3872
- throw new Error(`unknown vertical '${slug}'`);
4439
+ throw substratError('not_found', `unknown vertical '${slug}'`);
3873
4440
  this.directory
3874
4441
  .prepare('UPDATE verticals SET tenant_provisioner = ? WHERE slug = ?')
3875
4442
  .run(granted ? 1 : 0, slug);
@@ -3878,7 +4445,7 @@ export class SqliteScopeHost {
3878
4445
  setVerticalEmailSender: async (actor, slug, granted) => {
3879
4446
  const existing = readVertical(slug);
3880
4447
  if (!existing)
3881
- throw new Error(`unknown vertical '${slug}'`);
4448
+ throw substratError('not_found', `unknown vertical '${slug}'`);
3882
4449
  this.directory
3883
4450
  .prepare('UPDATE verticals SET email_sender = ? WHERE slug = ?')
3884
4451
  .run(granted ? 1 : 0, slug);
@@ -3887,7 +4454,7 @@ export class SqliteScopeHost {
3887
4454
  deleteVertical: async (actor, slug) => {
3888
4455
  const existing = readVertical(slug);
3889
4456
  if (!existing)
3890
- throw new Error(`unknown vertical '${slug}'`);
4457
+ throw substratError('not_found', `unknown vertical '${slug}'`);
3891
4458
  // Refuse while any restorable scope is bound: a deleted registry row would strand
3892
4459
  // those scopes' version pins and routing. An `archived` scope (a deleted app) still
3893
4460
  // blocks — unarchive can bring it back — but the refusal names reap/restore, not
@@ -3900,10 +4467,10 @@ export class SqliteScopeHost {
3900
4467
  'FROM scopes WHERE vertical = ?')
3901
4468
  .get(slug);
3902
4469
  if (bound.live > 0) {
3903
- throw new Error(`vertical '${slug}' still backs ${bound.live} scope(s) — delete or rebind them first`);
4470
+ throw substratError('conflict', `vertical '${slug}' still backs ${bound.live} scope(s) — delete or rebind them first`);
3904
4471
  }
3905
4472
  if (bound.archived > 0) {
3906
- throw new Error(`vertical '${slug}' still backs ${bound.archived} archived scope(s) — reap or restore them first`);
4473
+ throw substratError('conflict', `vertical '${slug}' still backs ${bound.archived} archived scope(s) — reap or restore them first`);
3907
4474
  }
3908
4475
  // Deployed dispatch scripts are NOT reaped here — they become orphans for the
3909
4476
  // cleanup script (#248), never destroyed alongside a registry row.
@@ -3916,7 +4483,7 @@ export class SqliteScopeHost {
3916
4483
  admitVersion: async (actor, versionId) => {
3917
4484
  const v = readVersion(versionId);
3918
4485
  if (!v)
3919
- throw new Error(`unknown version ${versionId}`);
4486
+ throw substratError('not_found', `unknown version ${versionId}`);
3920
4487
  if (v.admission === 'admitted') {
3921
4488
  // Idempotent — except an AUTO-admitted version, which this upgrades to a
3922
4489
  // manual vouch by clearing the note (what the publish seam requires).
@@ -3932,7 +4499,7 @@ export class SqliteScopeHost {
3932
4499
  return;
3933
4500
  }
3934
4501
  if (v.admission === 'rejected') {
3935
- throw new Error(`version ${versionId} was rejected — publish a new one`);
4502
+ throw substratError('conflict', `version ${versionId} was rejected — publish a new one`);
3936
4503
  }
3937
4504
  this.directory
3938
4505
  .prepare("UPDATE vertical_versions SET admission = 'admitted' WHERE id = ?")
@@ -3944,7 +4511,7 @@ export class SqliteScopeHost {
3944
4511
  rejectVersion: async (actor, versionId, note) => {
3945
4512
  const v = readVersion(versionId);
3946
4513
  if (!v)
3947
- throw new Error(`unknown version ${versionId}`);
4514
+ throw substratError('not_found', `unknown version ${versionId}`);
3948
4515
  if (v.admission === 'admitted') {
3949
4516
  throw new Error(`version ${versionId} is already admitted — it may be bound`);
3950
4517
  }
@@ -3961,7 +4528,7 @@ export class SqliteScopeHost {
3961
4528
  promoteVersion: async (actor, verticalSlug, channel, versionId, acknowledge) => {
3962
4529
  const incoming = readVersion(versionId);
3963
4530
  if (!incoming)
3964
- throw new Error(`unknown version ${versionId}`);
4531
+ throw substratError('not_found', `unknown version ${versionId}`);
3965
4532
  if (incoming.verticalSlug !== verticalSlug) {
3966
4533
  throw new Error(`version ${versionId} belongs to '${incoming.verticalSlug}'`);
3967
4534
  }
@@ -4091,7 +4658,7 @@ export class SqliteScopeHost {
4091
4658
  bindScopeVersion: async (actor, tenantId, scopeId, versionId, opts) => {
4092
4659
  const v = readVersion(versionId);
4093
4660
  if (!v)
4094
- throw new Error(`unknown version ${versionId}`);
4661
+ throw substratError('not_found', `unknown version ${versionId}`);
4095
4662
  const scope = this.directory
4096
4663
  .prepare('SELECT tenant_id, kind, vertical_version_id FROM scopes WHERE scope_id = ?')
4097
4664
  .get(scopeId);
@@ -4147,7 +4714,7 @@ export class SqliteScopeHost {
4147
4714
  .prepare('SELECT * FROM verticals WHERE slug = ?')
4148
4715
  .get(verticalSlug);
4149
4716
  if (!r)
4150
- throw new Error(`unknown vertical '${verticalSlug}'`);
4717
+ throw substratError('not_found', `unknown vertical '${verticalSlug}'`);
4151
4718
  this.recordAccess(actor, 'verticalServing', {}, { verticalSlug }, r.serving_ref ? 1 : 0);
4152
4719
  if (!r.serving_ref || !r.serving_version_id || !r.serving_migration_tag)
4153
4720
  return null;
@@ -4164,7 +4731,7 @@ export class SqliteScopeHost {
4164
4731
  .prepare('SELECT * FROM verticals WHERE slug = ?')
4165
4732
  .get(verticalSlug);
4166
4733
  if (!r)
4167
- throw new Error(`unknown vertical '${verticalSlug}'`);
4734
+ throw substratError('not_found', `unknown vertical '${verticalSlug}'`);
4168
4735
  this.directory
4169
4736
  .prepare(`UPDATE verticals SET serving_ref = ?, serving_version_id = ?,
4170
4737
  serving_do_classes = ?, serving_migration_tag = ? WHERE slug = ?`)
@@ -4176,7 +4743,7 @@ export class SqliteScopeHost {
4176
4743
  .prepare('SELECT * FROM vertical_versions WHERE id = ?')
4177
4744
  .get(versionId);
4178
4745
  if (!v || v.vertical_slug !== verticalSlug) {
4179
- throw new Error(`unknown version ${versionId} for vertical '${verticalSlug}'`);
4746
+ throw substratError('not_found', `unknown version ${versionId} for vertical '${verticalSlug}'`);
4180
4747
  }
4181
4748
  this.recordAccess(actor, 'versionManifest', {}, { verticalSlug, versionId }, v.manifest_json ? 1 : 0);
4182
4749
  return v.manifest_json;
@@ -4206,24 +4773,18 @@ export class SqliteScopeHost {
4206
4773
  this.recordAdmin(actor, 'setScopeExpiresAt', { tenantId, scopeId }, { expiresAt: scope.expires_at }, { expiresAt });
4207
4774
  },
4208
4775
  readUndrainedEvents: async (actor, tenantId, scopeId, limit) => {
4209
- const db = this.scopeDbFor(tenantId, scopeId);
4210
- const rows = db
4211
- .prepare(`SELECT * FROM _substrat_outbox WHERE drained_at IS NULL ORDER BY id LIMIT ?`)
4212
- .all(Math.min(Math.max(limit ?? 200, 1), 1000));
4213
- this.recordAccess(actor, 'readUndrainedEvents', { tenantId, scopeId }, { limit }, rows.length);
4214
- return rows.map((r) => ({
4215
- ...this.parseOutboxRow(r),
4216
- operation: r.operation ?? null,
4217
- version: r.version ?? null,
4218
- // #1237 — lifted like the two above, and for the same reason: the column
4219
- // exists on the outbox but not on the envelope `parseOutboxRow` returns,
4220
- // whose `domainEvent.parse` strips anything it does not declare.
4221
- causedBy: r.caused_by ?? null,
4222
- // Same lift, same reason. `drainedEvent` requires this field, and the `as never`
4223
- // below is what let it be missed: without it a SQLite-backed lake loses the
4224
- // call grouping permanently, while a Cloudflare-backed one keeps it.
4225
- invocationId: r.invocation_id ?? null,
4226
- }));
4776
+ // The COMMITTED outbox (#1624). On the shared connection this read saw an in-flight
4777
+ // operation's uncommitted event, the drain shipped it, and the rollback left the lake
4778
+ // — which is append-only — holding an event the scope never had. The mark that
4779
+ // follows needs no such care: it stamps only rows that exist and are undrained.
4780
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4781
+ const page = db.prepare(`SELECT * FROM _substrat_outbox WHERE drained_at IS NULL ORDER BY id LIMIT ? OFFSET ?`);
4782
+ // The kernel's read, shared with the DO: the envelope and the columns lifted beside
4783
+ // it (#1242, #1237) are decoded once, and a row that will not decode is stepped over
4784
+ // rather than stalling the scope on it (#1636).
4785
+ const read = readUndrainedOutbox((offset, count) => page.all(count, offset), Math.min(Math.max(limit ?? 200, 1), 1000));
4786
+ this.recordAccess(actor, 'readUndrainedEvents', { tenantId, scopeId }, { limit }, read.events.length);
4787
+ return undrainedEventsOf(read);
4227
4788
  },
4228
4789
  markEventsDrained: async (actor, tenantId, scopeId, eventIds) => {
4229
4790
  // BEFORE the empty-batch shortcut, not after. "Nothing to mark" and "you may
@@ -4244,12 +4805,16 @@ export class SqliteScopeHost {
4244
4805
  // cannot absorb: the next read resumes from the middle of a batch already
4245
4806
  // in the lake, and the object it writes overlaps the one already there —
4246
4807
  // the duplication the first-id object key exists to prevent.
4247
- const drained = db.transaction(() => {
4808
+ //
4809
+ // On the scope actor (#1678): issued mid-invoke, this transaction was a SAVEPOINT
4810
+ // inside the invoke's, and its rollback un-marked a batch already in the lake — so
4811
+ // the next pass shipped it again, the same duplicate by another road.
4812
+ const drained = await this.runtime(tenantId, scopeId).actor.turn(() => db.transaction(() => {
4248
4813
  let n = 0;
4249
4814
  for (const id of eventIds)
4250
4815
  n += stmt.run(at, id).changes;
4251
4816
  return n;
4252
- })();
4817
+ })());
4253
4818
  // K-24's rule, one tier down (#1334): declaring domain payloads shipped is an
4254
4819
  // EGRESS, and the admin log is where "these events left the platform, at this
4255
4820
  // time, on this actor's say-so" is recorded. `drainAccessLog` audits the same
@@ -4265,12 +4830,35 @@ export class SqliteScopeHost {
4265
4830
  // Directory check first, on mark's reasoning: "nothing to reopen" and "you may not
4266
4831
  // address this scope" are different answers.
4267
4832
  this.assertScopeReachable(tenantId, scopeId);
4268
- const { drainedBefore } = redrainEventsInput.parse(input);
4833
+ const { drainedBefore, countOnly } = redrainEventsInput.parse(input);
4269
4834
  // The window rule, enforced HERE and not only at the control-plane door: this verb
4270
4835
  // is public `HostAdmin`, so the fleet script reaches it without passing that route.
4271
- // The injected clock, so a test pins the boundary rather than racing it.
4836
+ // The injected clock, so a test pins the boundary rather than racing it. A count is
4837
+ // held to it too — the two answers come from one verb and one door.
4272
4838
  assertRedrainWindow(drainedBefore, this.clock());
4273
4839
  const db = this.scopeDbFor(tenantId, scopeId);
4840
+ // A COUNT reopens nothing, writes nothing, and records nothing (#1545). The two
4841
+ // receipts below are there because a reopen is a second egress of a tenant's
4842
+ // payloads; a count egresses none, and an admin row claiming a redrain on a scope
4843
+ // that was only counted is a false statement in the one log that is evidence.
4844
+ //
4845
+ // UNBOUNDED, unlike the reopen under it: the batch exists because an UPDATE over
4846
+ // the whole window must fit one Durable-Object request on the other adapter, and an
4847
+ // aggregate materialises no rows. A capped count would answer "5000, or more",
4848
+ // which is not an answer.
4849
+ //
4850
+ // It IS a read, and K-24 takes all reads rather than a chosen subset, so it leaves
4851
+ // an access row — the count itself as the result count, which is the fact an
4852
+ // auditor asking "who counted this tenant's outbox, and how much was there" wants.
4853
+ if (countOnly) {
4854
+ // The committed count (#1624): a stranger's uncommitted drain mark is not a fact yet.
4855
+ const redrainable = this.scopeReadDbFor(tenantId, scopeId)
4856
+ .prepare(`SELECT COUNT(*) AS c FROM _substrat_outbox
4857
+ WHERE drained_at IS NOT NULL AND drained_at < ?`)
4858
+ .get(drainedBefore).c;
4859
+ this.recordAccess(actor, 'redrainEvents', { tenantId, scopeId }, { drainedBefore, countOnly: true }, redrainable);
4860
+ return redrainable;
4861
+ }
4274
4862
  // Audit FIRST, matching the Cloudflare adapter and `rewindScope` (K-33): the row is
4275
4863
  // a separate statement outside the update's transaction, so a failure between them
4276
4864
  // would leave a reopen with no receipt — and the retry cannot repair it, because the
@@ -4288,14 +4876,17 @@ export class SqliteScopeHost {
4288
4876
  // is never pruned, so the unbounded form scales with the scope's whole history.
4289
4877
  // SQLite is built here without UPDATE...LIMIT, so the batch is a subselect over the
4290
4878
  // primary key — oldest first, so a partial run leaves a prefix rather than holes.
4291
- const redrained = db
4879
+ //
4880
+ // On the scope actor (#1678), so a stranger's rollback cannot undo a reopen this
4881
+ // verb has already counted and receipted.
4882
+ const redrained = await this.runtime(tenantId, scopeId).actor.turn(() => db
4292
4883
  .prepare(`UPDATE _substrat_outbox SET drained_at = NULL
4293
- WHERE id IN (
4294
- SELECT id FROM _substrat_outbox
4295
- WHERE drained_at IS NOT NULL AND drained_at < ?
4296
- ORDER BY id LIMIT ?
4297
- )`)
4298
- .run(drainedBefore, REDRAIN_BATCH).changes;
4884
+ WHERE id IN (
4885
+ SELECT id FROM _substrat_outbox
4886
+ WHERE drained_at IS NOT NULL AND drained_at < ?
4887
+ ORDER BY id LIMIT ?
4888
+ )`)
4889
+ .run(drainedBefore, REDRAIN_BATCH).changes);
4299
4890
  // The outcome beside the intent above — only when something changed, so a re-run
4300
4891
  // over an already reopened window leaves no row claiming it did something.
4301
4892
  if (redrained > 0) {
@@ -4306,7 +4897,7 @@ export class SqliteScopeHost {
4306
4897
  facetEvents: async (actor, tenantId, scopeId, input) => {
4307
4898
  // `facetEvents` is the sanctioned read: an erased payload yields the same
4308
4899
  // NULL a missing field does, and only the helper counts them apart.
4309
- const db = this.scopeDbFor(tenantId, scopeId);
4900
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4310
4901
  const result = facetEvents({ sql: scopedSql(db) }, input);
4311
4902
  this.recordAccess(actor, 'facetEvents', { tenantId, scopeId }, input, result.buckets.length);
4312
4903
  return result;
@@ -4316,7 +4907,7 @@ export class SqliteScopeHost {
4316
4907
  // only one that decodes the envelope's nullable facts (an erased payload, an
4317
4908
  // unrecorded authorization chain, nobody impersonating) as facts rather than
4318
4909
  // as missing data.
4319
- const db = this.scopeDbFor(tenantId, scopeId);
4910
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4320
4911
  const page = readHistory({ sql: scopedSql(db) }, { entityType: input.entityType, entityId: input.entityId }, { limit: input.limit, cursor: input.cursor });
4321
4912
  this.recordAccess(actor, 'entityHistory', { tenantId, scopeId }, { entityType: input.entityType, entityId: input.entityId }, page.entries.length);
4322
4913
  return page;
@@ -4325,7 +4916,7 @@ export class SqliteScopeHost {
4325
4916
  // #1237: the sanctioned walk. The helper owns the one distinction the whole
4326
4917
  // view rests on — a null cause with an operation is the beginning of the
4327
4918
  // chain, a null cause without one is the trail running out.
4328
- const db = this.scopeDbFor(tenantId, scopeId);
4919
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4329
4920
  const result = walkEventCause({ sql: scopedSql(db) }, input.eventId, input.maxDepth);
4330
4921
  this.recordAccess(actor, 'eventCause', { tenantId, scopeId }, { eventId: input.eventId }, result.chain.length);
4331
4922
  return result;
@@ -4333,27 +4924,27 @@ export class SqliteScopeHost {
4333
4924
  eventEffects: async (actor, tenantId, scopeId, input) => {
4334
4925
  // #1237 forward: the helper owns the two delivery traps — `delivered_at`
4335
4926
  // meaning two different things, and an empty list being ambiguous.
4336
- const db = this.scopeDbFor(tenantId, scopeId);
4927
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4337
4928
  const tree = walkEventEffects({ sql: scopedSql(db) }, input.eventId, input.maxNodes);
4338
4929
  this.recordAccess(actor, 'eventEffects', { tenantId, scopeId }, { eventId: input.eventId }, tree.count);
4339
4930
  return tree;
4340
4931
  },
4341
4932
  invocationEvents: async (actor, tenantId, scopeId, input) => {
4342
4933
  // #1237: one call's events, siblings included — what neither walk reaches.
4343
- const db = this.scopeDbFor(tenantId, scopeId);
4934
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4344
4935
  const read = readInvocation({ sql: scopedSql(db) }, input.invocationId, input.limit);
4345
4936
  this.recordAccess(actor, 'invocationEvents', { tenantId, scopeId }, { invocationId: input.invocationId }, read.events.length);
4346
4937
  return read;
4347
4938
  },
4348
4939
  deadLetters: async (actor, tenantId, scopeId, input) => {
4349
4940
  // #1525: every delivery that gave up, scope-wide — what the walks reach only by event.
4350
- const db = this.scopeDbFor(tenantId, scopeId);
4941
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4351
4942
  const page = readDeadLetters({ sql: scopedSql(db) }, { limit: input.limit, cursor: input.cursor });
4352
4943
  this.recordAccess(actor, 'deadLetters', { tenantId, scopeId }, null, page.entries.length);
4353
4944
  return page;
4354
4945
  },
4355
4946
  scopeAppliedMigrations: async (actor, tenantId, scopeId) => {
4356
- const db = this.scopeDbFor(tenantId, scopeId);
4947
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4357
4948
  const rows = db
4358
4949
  .prepare(`SELECT module_id, version, applied_at FROM _substrat_migrations
4359
4950
  ORDER BY applied_at DESC, module_id, version LIMIT 100`)
@@ -4377,6 +4968,16 @@ export class SqliteScopeHost {
4377
4968
  this.recordAccess(actor, 'scopeMigrationBookmarks', { tenantId, scopeId }, null, 0);
4378
4969
  return [];
4379
4970
  },
4971
+ scopeDatabaseSize: async (actor, tenantId, scopeId) => {
4972
+ // #1524: the SQLite twin of a DO's `databaseSize`. It counts allocated pages,
4973
+ // free ones included, which is what the file costs on disk, not the live row bytes.
4974
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4975
+ const pages = db.pragma('page_count', { simple: true });
4976
+ const pageSize = db.pragma('page_size', { simple: true });
4977
+ const bytes = pages * pageSize;
4978
+ this.recordAccess(actor, 'scopeDatabaseSize', { tenantId, scopeId }, null, 1);
4979
+ return bytes;
4980
+ },
4380
4981
  rewindScope: async () => {
4381
4982
  throw new Error('point-in-time rewind is not available on this host (PITR is a Durable-Object-plane mechanism) — use the backup restore path');
4382
4983
  },
@@ -4609,7 +5210,7 @@ export class SqliteScopeHost {
4609
5210
  listScopeTables: async (actor, tenantId, scopeId) => {
4610
5211
  // K-3: the (tenantId, scopeId) pair is cross-checked before we open anything;
4611
5212
  // a scope under a different tenant is unreachable, never another tenant's DB.
4612
- const db = this.scopeDbFor(tenantId, scopeId);
5213
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4613
5214
  const names = db
4614
5215
  .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`)
4615
5216
  .all().map((r) => r.name);
@@ -4622,7 +5223,7 @@ export class SqliteScopeHost {
4622
5223
  return tables;
4623
5224
  },
4624
5225
  readScopeTable: async (actor, tenantId, scopeId, input) => {
4625
- const db = this.scopeDbFor(tenantId, scopeId);
5226
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4626
5227
  // Validate the table against the LIVE schema — an unknown name throws, it is
4627
5228
  // never interpolated blind. That validated name is the only thing that reaches
4628
5229
  // the query, so the quoted identifier below carries no user input.
@@ -4639,7 +5240,7 @@ export class SqliteScopeHost {
4639
5240
  return { table: input.table, columns, rows, rowCount, limit, offset };
4640
5241
  },
4641
5242
  queryScope: async (actor, tenantId, scopeId, input) => {
4642
- const db = this.scopeDbFor(tenantId, scopeId);
5243
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4643
5244
  // Layer 1, shared: the kernel's textual gate (single statement, read verbs
4644
5245
  // only) — the checked statement is the one that runs.
4645
5246
  const sql = assertReadOnlyQuery(input.sql);
@@ -4674,14 +5275,14 @@ export class SqliteScopeHost {
4674
5275
  // read records to the K-24 access log. What differs is that these two answer a
4675
5276
  // question about the permission model rather than about the data.
4676
5277
  listDenials: async (actor, tenantId, scopeId, filter) => {
4677
- const db = this.scopeDbFor(tenantId, scopeId);
5278
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4678
5279
  const q = denialListQuery(filter);
4679
5280
  const rows = db.prepare(q.sql).all(...q.params).map(mapDenialRow);
4680
5281
  this.recordAccess(actor, 'listDenials', { tenantId, scopeId }, filter ?? null, rows.length);
4681
5282
  return rows;
4682
5283
  },
4683
5284
  summarizeDenials: async (actor, tenantId, scopeId, filter) => {
4684
- const db = this.scopeDbFor(tenantId, scopeId);
5285
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4685
5286
  const b = denialSummaryQuery(filter);
4686
5287
  // Grouped as the filter asked (#1456), and the answer says which way.
4687
5288
  const grouped = mapDenialSummaryBuckets(b.groupBy, db.prepare(b.sql).all(...b.params));
@@ -4701,7 +5302,7 @@ export class SqliteScopeHost {
4701
5302
  },
4702
5303
  exportScope: async (actor, tenantId, scopeId) => {
4703
5304
  // K-3: cross-check the pair before opening anything (same as the introspection reads).
4704
- const db = this.scopeDbFor(tenantId, scopeId);
5305
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4705
5306
  // Every real table, keeping the `_substrat_*` spine but dropping SQLite's own
4706
5307
  // `sqlite_*` internals — those are auto-managed and `CREATE TABLE sqlite_*` is
4707
5308
  // rejected on reload. `sql` is the CREATE statement, replayed to rebuild the schema.
@@ -4842,13 +5443,11 @@ export class SqliteScopeHost {
4842
5443
  throw new Error(`scope ${scopeId} still resolves hostname '${bound.hostname}' — ` +
4843
5444
  `unbind it before reaping (reap wipes storage and cannot be undone)`);
4844
5445
  }
4845
- const key = `${tenantId}/${scopeId}`;
4846
- const rt = this.scopes.get(key);
4847
- if (rt) {
4848
- rt.db.close();
4849
- this.scopes.delete(key);
4850
- this.scopesById.delete(scopeId);
4851
- }
5446
+ // The reader goes with the writer (#1624): an open handle on a removed file keeps
5447
+ // reading the unlinked bytes, which is the reaped data answering as if it were live.
5448
+ const rt = this.scopes.get(`${tenantId}/${scopeId}`);
5449
+ if (rt)
5450
+ this.closeRuntime(rt);
4852
5451
  rmSync(join(this.dir, `${tenantId}__${scopeId}.sqlite`), { force: true });
4853
5452
  // The recoverable copy the caller stored first (#493), named in the audit entry so
4854
5453
  // the trail answers "was there a backup" without correlating two timestamps.
@@ -4887,15 +5486,31 @@ export class SqliteScopeHost {
4887
5486
  // and transaction facts remain". A consumer's timeline still shows that something
4888
5487
  // happened, to what, and when; it no longer shows who or what was said.
4889
5488
  const db = this.scopeDbFor(tenantId, scopeId);
4890
- const redacted = db
4891
- .prepare(`UPDATE _substrat_outbox SET payload = NULL
4892
- WHERE subject_id = ? AND pii_class != 'none' AND payload IS NOT NULL`)
4893
- .run(subjectId);
5489
+ // One instant for the whole erasure, read before the first write: the intent
5490
+ // tombstones below and the key's own tombstone should not disagree about when a
5491
+ // person was erased.
4894
5492
  const at = new Date().toISOString();
5493
+ // Both scope-side redactions in ONE turn on the scope actor (#1678): issued while an
5494
+ // invoke held its transaction open, they joined it, and its rollback put the
5495
+ // person's PII back after this verb had destroyed the key and receipted the erasure.
5496
+ const { redacted, intentsRedacted } = await this.runtime(tenantId, scopeId).actor.turn(() => ({
5497
+ redacted: db
5498
+ .prepare(`UPDATE _substrat_outbox SET payload = NULL
5499
+ WHERE subject_id = ? AND pii_class != 'none' AND payload IS NOT NULL`)
5500
+ .run(subjectId),
5501
+ // The spine's OTHER copy of an event (#1600). A CP-less host routes a connector
5502
+ // delivery it cannot run into `_substrat_platform_requests` with the whole event
5503
+ // embedded, and nothing ever deletes those rows — so the same line has to be held
5504
+ // here, or the redaction above only moves the name one table across. Same
5505
+ // idempotence as the outbox half: a re-run finds the tombstones and changes
5506
+ // nothing.
5507
+ intentsRedacted: this.redactSubjectIntents(db, subjectId, at),
5508
+ }));
4895
5509
  const { existed } = await this.subjectKeysFor(tenantId, scopeId).destroy(subjectId, at);
4896
5510
  const receipt = subjectShredReceipt.parse({
4897
5511
  subjectId,
4898
5512
  eventsRedacted: redacted.changes,
5513
+ intentsRedacted,
4899
5514
  keyDestroyed: existed,
4900
5515
  tombstoned: true,
4901
5516
  });
@@ -4903,7 +5518,9 @@ export class SqliteScopeHost {
4903
5518
  // mutation, the access log because it destroys evidence. An erasure is the one
4904
5519
  // action where "who asked for this to disappear" is itself the record.
4905
5520
  this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
4906
- this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, redacted.changes);
5521
+ // BOTH counts: the access log's number is "how much evidence this destroyed", and
5522
+ // an intent payload is a whole event's worth of it.
5523
+ this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, redacted.changes + intentsRedacted);
4907
5524
  return receipt;
4908
5525
  },
4909
5526
  // -- impersonation (K-42, #868) ----------------------------------------
@@ -5121,6 +5738,40 @@ export class SqliteScopeHost {
5121
5738
  this.recordAccess(actor, 'listIdentityTenants', {}, { provider }, tenants.length);
5122
5739
  return tenants;
5123
5740
  },
5741
+ listIdentityMemberships: async (actor, provider, externalId) => {
5742
+ const pool = readPool(provider);
5743
+ if (!pool)
5744
+ throw new Error(`identity pool '${provider}' is not registered`);
5745
+ if (pool.topology !== 'central') {
5746
+ throw new Error(`identity pool '${provider}' is tenant-bound — enumerating tenants is only ` +
5747
+ `meaningful on a central pool, where the same externalId is the same person`);
5748
+ }
5749
+ const rows = this.directory
5750
+ .prepare(`SELECT i.tenant_id, i.principal_id, s.scope_id, s.vertical, s.status AS scope_status
5751
+ FROM _substrat_identities i
5752
+ LEFT JOIN scopes s ON s.scope_id = i.scope_id AND s.tenant_id = i.tenant_id
5753
+ WHERE i.provider = ? AND i.external_id = ?
5754
+ ORDER BY i.tenant_id`)
5755
+ .all(provider, externalId);
5756
+ const memberships = rows.flatMap((r) => {
5757
+ // A link whose tenant row is gone names nothing a caller could land in.
5758
+ const tenant = readTenant(r.tenant_id);
5759
+ if (!tenant)
5760
+ return [];
5761
+ return [
5762
+ identityMembership.parse({
5763
+ tenant,
5764
+ principal: r.principal_id,
5765
+ scope: r.scope_id === null
5766
+ ? null
5767
+ : { id: r.scope_id, vertical: r.vertical, status: r.scope_status ?? 'active' },
5768
+ }),
5769
+ ];
5770
+ });
5771
+ // One row for the whole answer — the read this replaces logged one per tenant.
5772
+ this.recordAccess(actor, 'listIdentityMemberships', {}, { provider }, memberships.length);
5773
+ return memberships;
5774
+ },
5124
5775
  listIdentityLinks: async (actor, tid) => {
5125
5776
  // The projection read (#406): what the platform gathers to deliver a tenant's
5126
5777
  // links with provisioning/reconcile — the same trust line entitlements ride
@@ -5826,6 +6477,44 @@ export class SqliteScopeHost {
5826
6477
  if (!existing)
5827
6478
  db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
5828
6479
  }
6480
+ /**
6481
+ * #1288: `_substrat_schedule_state`, rebuilt with `kind` in its key on a scope db
6482
+ * whose table predates the column. The statements are the kernel's, so the DO
6483
+ * adapter rebuilds byte-identically, and the detection is the `sqlite_master.sql`
6484
+ * read `ensureIdentityKey` already uses — PRAGMA would do here, but not there.
6485
+ */
6486
+ ensureScheduleStateKind(db) {
6487
+ const row = db
6488
+ .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
6489
+ .get('_substrat_schedule_state');
6490
+ if (!row || scheduleStateHasKind(row.sql))
6491
+ return;
6492
+ // In a transaction, for the reason the kernel constant spells out: create-copy-
6493
+ // drop-rename has two intermediate states and BOTH are unrecoverable on the next
6494
+ // wake — one dies on the leftover scratch table, one silently orphans the rows
6495
+ // behind an empty table `CREATE TABLE IF NOT EXISTS` put back. `db.transaction`
6496
+ // nests as a SAVEPOINT, which is what makes this safe on the `loadDump` path too,
6497
+ // where the whole replay is already inside one.
6498
+ db.transaction(() => db.exec(SCHEDULE_STATE_REBUILD))();
6499
+ }
6500
+ /**
6501
+ * #1573: run a create-copy-drop-rename against the directory as ONE transaction.
6502
+ * `exec` of four statements is four autocommits, and the state between DROP and
6503
+ * RENAME is the one to fear: the next open runs the bootstrap first, whose `CREATE
6504
+ * TABLE IF NOT EXISTS` puts an EMPTY table of the new shape back, detection then
6505
+ * reads that shape and concludes the migration is done — and every copied row stays
6506
+ * orphaned in the `_new` table, with nothing ever erroring. Atomic, neither
6507
+ * intermediate state is reachable, so there is no recovery path to write (or to
6508
+ * leave untested).
6509
+ *
6510
+ * `db.transaction` nests as a SAVEPOINT, as `ensureScheduleStateKind` (#1571) relies
6511
+ * on. Each script leads with `DROP TABLE IF EXISTS <table>_new` to absorb a scratch
6512
+ * table arriving from BELOW the transaction (a torn copy of the file, a backup that
6513
+ * captured one mid-rebuild) — belt, not the fix.
6514
+ */
6515
+ rebuildAtomically(script) {
6516
+ this.directory.transaction(() => this.directory.exec(script))();
6517
+ }
5829
6518
  /**
5830
6519
  * Rebuild `_substrat_identities` when it still carries the pre-K-22 global key.
5831
6520
  * A PRIMARY KEY cannot be ALTERed, so this is create-copy-drop-rename.
@@ -5846,7 +6535,9 @@ export class SqliteScopeHost {
5846
6535
  .get('_substrat_identities');
5847
6536
  if (!row || row.sql.includes('PRIMARY KEY (tenant_id, provider, external_id)'))
5848
6537
  return;
5849
- this.directory.exec(`
6538
+ // #1573: one transaction — see `rebuildAtomically`.
6539
+ this.rebuildAtomically(`
6540
+ DROP TABLE IF EXISTS _substrat_identities_new;
5850
6541
  CREATE TABLE _substrat_identities_new (
5851
6542
  provider TEXT NOT NULL,
5852
6543
  external_id TEXT NOT NULL,
@@ -5877,7 +6568,9 @@ export class SqliteScopeHost {
5877
6568
  .get('_substrat_admin_log');
5878
6569
  if (!row || !/tenant_id TEXT NOT NULL/.test(row.sql))
5879
6570
  return;
5880
- this.directory.exec(`
6571
+ // #1573: one transaction — see `rebuildAtomically`.
6572
+ this.rebuildAtomically(`
6573
+ DROP TABLE IF EXISTS _substrat_admin_log_new;
5881
6574
  CREATE TABLE _substrat_admin_log_new (
5882
6575
  id TEXT PRIMARY KEY,
5883
6576
  actor TEXT NOT NULL,
@@ -5896,6 +6589,30 @@ export class SqliteScopeHost {
5896
6589
  ALTER TABLE _substrat_admin_log_new RENAME TO _substrat_admin_log;
5897
6590
  `);
5898
6591
  }
6592
+ /**
6593
+ * #1572: `_substrat_sweep_runs_intent` with `kind` in it, on a directory whose index
6594
+ * predates that. `CREATE UNIQUE INDEX IF NOT EXISTS` matches on the name alone, so the
6595
+ * bootstrap cannot do this: on an existing directory it sees the old index and skips,
6596
+ * and the schedule/freshness collision the new key refuses stays live for good.
6597
+ *
6598
+ * Detected from `sqlite_master.sql`, like the table rebuilds above, and replaced as
6599
+ * DROP-then-CREATE in one transaction (`rebuildAtomically`). Less rides on that than on
6600
+ * a table rebuild — the index holds no rows, and one dropped but not re-created comes
6601
+ * back on the next open, because the bootstrap creates it by name — but there is then
6602
+ * no committed directory with no dedupe key at all. The CREATE cannot meet a row it
6603
+ * refuses: every set of rows unique on (request_id, unit) is unique on the wider key.
6604
+ */
6605
+ ensureSweepRunsIntentKind() {
6606
+ const row = this.directory
6607
+ .prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?")
6608
+ .get('_substrat_sweep_runs_intent');
6609
+ if (row && sweepRunsIntentHasKind(row.sql))
6610
+ return;
6611
+ this.rebuildAtomically(`
6612
+ DROP INDEX IF EXISTS _substrat_sweep_runs_intent;
6613
+ ${SWEEP_RUNS_INTENT_INDEX}
6614
+ `);
6615
+ }
5899
6616
  ensureDirectoryColumns() {
5900
6617
  this.ensureIdentityKey();
5901
6618
  this.ensureAdminLogTenantNullable();
@@ -5964,7 +6681,7 @@ export class SqliteScopeHost {
5964
6681
  this.ensureColumn(this.directory, '_substrat_sweep_runs', 'request_id', 'request_id TEXT');
5965
6682
  this.ensureColumn(this.directory, '_substrat_sweep_runs', 'event_type', 'event_type TEXT');
5966
6683
  this.ensureColumn(this.directory, '_substrat_sweep_runs', 'observed_at', 'observed_at TEXT');
5967
- this.directory.exec('CREATE UNIQUE INDEX IF NOT EXISTS _substrat_sweep_runs_intent ON _substrat_sweep_runs (request_id, unit)');
6684
+ this.ensureSweepRunsIntentKind();
5968
6685
  const existing = new Set(this.directory.prepare('PRAGMA table_info(scopes)').all().map((c) => c.name));
5969
6686
  for (const [column, ddl] of [
5970
6687
  ['parent_scope_id', 'parent_scope_id TEXT'],
@@ -6041,7 +6758,23 @@ export class SqliteScopeHost {
6041
6758
  * onto every event it emits. Absent for consumer dispatch: a consumer runs on
6042
6759
  * behalf of no operation, and the emitted row's NULL says so.
6043
6760
  */
6044
- operation) {
6761
+ operation,
6762
+ /**
6763
+ * #1672: the secrets `ctx.capabilities.mint` hands out during this invocation. The
6764
+ * stub owns the array (it withholds them from the idempotency recording); this
6765
+ * context appends to it and holds `ctx.sql`, `ctx.emit` and `ctx.requestPlatform` to
6766
+ * it — the tripwire that catches a module persisting one by accident (not a boundary
6767
+ * against one that means to; see `assertNoSecret`).
6768
+ */
6769
+ minted = [],
6770
+ /**
6771
+ * #1672: the instant to stamp, when the caller already read one. The exchange passes the
6772
+ * instant its row write used, so the `capability.exercised` event's `occurredAt` and the
6773
+ * row's `last_used_at` are the same value — a second clock read could disagree.
6774
+ */
6775
+ at = this.clock()) {
6776
+ // For a connection, system or capability subject this carries THAT id so the type
6777
+ // holds — it is not a person, and the event actor below says what it is instead.
6045
6778
  const principal = subject.id;
6046
6779
  const checker = this.checker;
6047
6780
  const relations = this.relations;
@@ -6066,7 +6799,6 @@ export class SqliteScopeHost {
6066
6799
  * `ctx.now()` deterministic only in the sense that each reading is separately
6067
6800
  * unpredictable.
6068
6801
  */
6069
- const at = this.clock();
6070
6802
  /**
6071
6803
  * The scope host's half of `ctx.atomic` (#770) — everything else is the
6072
6804
  * kernel's (`createAtomic`). SQLite gives us savepoints, which nest inside
@@ -6102,7 +6834,10 @@ export class SqliteScopeHost {
6102
6834
  // Lifted out of the object literal so `grant` can reuse it: a delegation
6103
6835
  // check has to be the SAME check the operation itself passes, or the two
6104
6836
  // could disagree about what the caller holds.
6105
- const runCheck = async (permission, entity) => {
6837
+ const runCheck = async (unparsed, entity) => {
6838
+ // #1642: parsed before the override actor's early return, which never reaches
6839
+ // the checker — a cast key would otherwise become that path's proof relation.
6840
+ const permission = assertPermissionKey(unparsed);
6106
6841
  if (overrideActor) {
6107
6842
  return {
6108
6843
  allowed: true,
@@ -6140,15 +6875,17 @@ export class SqliteScopeHost {
6140
6875
  scopeId: rt.scopeId,
6141
6876
  });
6142
6877
  };
6143
- return {
6878
+ const ctxRef = {
6144
6879
  tenantId: rt.tenantId,
6145
6880
  scopeId: rt.scopeId,
6146
6881
  principal,
6147
- sql: scopedSql(rt.db),
6882
+ sql: guardSecrets(scopedSql(rt.db), minted),
6148
6883
  now: () => at,
6149
6884
  emit: (event) => {
6150
6885
  assertImpersonationWrites(impersonation, 'ctx.emit');
6151
6886
  const input = domainEventInput.parse(event);
6887
+ // #1672: the COMPLETE parsed event — entity id, type and subject as well as payload.
6888
+ assertNoSecret('ctx.emit', input, minted);
6152
6889
  const full = domainEvent.parse({
6153
6890
  ...input,
6154
6891
  // #956: from the operation's instant, not the wall clock. `ORDER BY id`
@@ -6159,12 +6896,7 @@ export class SqliteScopeHost {
6159
6896
  occurredAt: at,
6160
6897
  tenantId: rt.tenantId,
6161
6898
  scopeId: rt.scopeId,
6162
- actor: overrideActor ??
6163
- (subject.kind === 'system'
6164
- ? { system: subject.id }
6165
- : subject.kind === 'connection'
6166
- ? { connection: subject.id }
6167
- : principal),
6899
+ actor: overrideActor ?? actorOf(subject),
6168
6900
  ...(passed.length ? { authorization: passed.map((p) => ({ ...p })) } : {}),
6169
6901
  ...(stamp ? { impersonation: stamp } : {}),
6170
6902
  // #1231: kernel-stamped like the two above — `domainEventInput.parse`
@@ -6194,6 +6926,8 @@ export class SqliteScopeHost {
6194
6926
  requestPlatform: (request) => {
6195
6927
  assertImpersonationWrites(impersonation, 'ctx.requestPlatform');
6196
6928
  const input = platformRequestInput.parse(request);
6929
+ // #1672: the COMPLETE parsed request — its `kind` is persisted as surely as its payload.
6930
+ assertNoSecret('ctx.requestPlatform', input, minted);
6197
6931
  // #1474: a platform-authored kind (`sweep-runs`) is the platform's to enqueue, never
6198
6932
  // module code's — its drain handler writes schedule verdicts the dashboard trusts.
6199
6933
  assertModuleEnqueueableKind(input.kind);
@@ -6206,12 +6940,7 @@ export class SqliteScopeHost {
6206
6940
  throw new Error(`too many pending platform requests (${pending}); retry once some have drained`);
6207
6941
  }
6208
6942
  const id = platformRequestId.parse(ulid());
6209
- const requestedBy = overrideActor ??
6210
- (subject.kind === 'system'
6211
- ? { system: subject.id }
6212
- : subject.kind === 'connection'
6213
- ? { connection: subject.id }
6214
- : principal);
6943
+ const requestedBy = overrideActor ?? actorOf(subject);
6215
6944
  rt.db
6216
6945
  .prepare(`INSERT INTO _substrat_platform_requests
6217
6946
  (id, kind, payload, requested_by, impersonation, status, attempts, requested_at)
@@ -6225,7 +6954,7 @@ export class SqliteScopeHost {
6225
6954
  // predicate is needed or possible: the runtime IS the scope.
6226
6955
  platformRequests: (filter) => {
6227
6956
  const q = platformRequestHistoryQuery(filter);
6228
- return rt.db.prepare(q.sql).all(...q.params).map(rowToPlatformRequest);
6957
+ return rt.db.prepare(q.sql).all(...q.params).map(platformRequestOf);
6229
6958
  },
6230
6959
  // #901. This scope's own spine, so no tenancy predicate is needed or
6231
6960
  // possible — the runtime IS the scope. The version is the last event's
@@ -6325,6 +7054,21 @@ export class SqliteScopeHost {
6325
7054
  .run(`principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`);
6326
7055
  },
6327
7056
  atomic: createAtomic(runSub, { passed, signals }),
7057
+ // #1672: mint / revoke / list, written once in the kernel. The raw spine seam (this
7058
+ // is the kernel's own write to `_substrat_capabilities`), the operation's OWN check —
7059
+ // the predicate `grant` shares — and `ctx.emit` for the minted/revoked events. A
7060
+ // consumer's override actor is passed as the system actor it is, so a consumer cannot
7061
+ // mint: its checks allow unconditionally, which would make "the minter holds it" vacuous.
7062
+ capabilities: createCapabilityVerbs({
7063
+ sql: spineSql(rt.db),
7064
+ subject: overrideActor ? { kind: 'system', id: overrideActor.system } : subject,
7065
+ now: at,
7066
+ check: runCheck,
7067
+ emit: (event) => ctxRef.emit(event),
7068
+ isOperation: (name) => this.operations.has(name),
7069
+ assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
7070
+ minted,
7071
+ }),
6328
7072
  link: (child, parent) => {
6329
7073
  assertImpersonationWrites(impersonation, 'ctx.link');
6330
7074
  const allowed = relations.get(child.entityType);
@@ -6386,6 +7130,7 @@ export class SqliteScopeHost {
6386
7130
  return sealTo({ keyId: key.keyId, publicKey: key.publicKey }, plaintext);
6387
7131
  },
6388
7132
  };
7133
+ return ctxRef;
6389
7134
  }
6390
7135
  async applyPendingMigrations(rt) {
6391
7136
  const pending = [];
@@ -6494,10 +7239,98 @@ export class SqliteScopeHost {
6494
7239
  throw new Error(`scope ${scopeId} is reaped — its storage is gone and cannot be read`);
6495
7240
  }
6496
7241
  }
7242
+ /**
7243
+ * The intent-journal half of `shredSubject` (#1600) — the DO's `redactSubject` twin.
7244
+ *
7245
+ * Row-by-row rather than one `UPDATE … WHERE`: the decision is structural (does this
7246
+ * payload embed an event the erasure redacts?) and SQL cannot make it, because an
7247
+ * intent payload is opaque JSON with no `pii_class` column to test. The SQL narrows to
7248
+ * the rows that could possibly match; the kernel predicate decides; the kernel's
7249
+ * statement writes. All three are shared with the Durable-Object adapter so the two
7250
+ * cannot drift about what a redacted intent is.
7251
+ *
7252
+ * Returns how many rows it redacted — zero on a re-run, the tombstones having already
7253
+ * stopped matching.
7254
+ */
7255
+ redactSubjectIntents(db, subjectId, at) {
7256
+ const q = platformRequestRedactionQuery(subjectId);
7257
+ const candidates = db.prepare(q.sql).all(...q.params);
7258
+ const write = db.prepare(PLATFORM_REQUEST_REDACTION_SQL);
7259
+ let redacted = 0;
7260
+ for (const row of candidates) {
7261
+ if (!intentPayloadCarriesSubject(row.payload, subjectId))
7262
+ continue;
7263
+ write.run(...platformRequestRedactionParams(row.id, subjectId, at));
7264
+ redacted += 1;
7265
+ }
7266
+ return redacted;
7267
+ }
6497
7268
  scopeDbFor(tenantId, scopeId) {
6498
7269
  this.assertScopeReachable(tenantId, scopeId);
6499
7270
  return this.runtime(tenantId, scopeId).db;
6500
7271
  }
7272
+ /**
7273
+ * The connection a `HostAdmin` READ runs on (#1624) — `scopeDbFor`'s twin, with the
7274
+ * same K-3 and reap gate in front of it.
7275
+ *
7276
+ * From OUTSIDE the scope's actor it is the scope's read-only connection. `invoke` holds
7277
+ * a `BEGIN IMMEDIATE` open on the shared connection across its awaits, so a read there
7278
+ * saw a stranger's uncommitted rows: a lake drain shipped an event that then rolled
7279
+ * back — permanently, since the lake is append-only — and an export captured one into a
7280
+ * backup. The file is WAL, so the reader sees exactly what was committed.
7281
+ *
7282
+ * From INSIDE one of the scope's actor tasks (an executor handed `HostAdmin`, reading
7283
+ * its own scope) it is the shared connection, as before. The only transaction open
7284
+ * there is the caller's own unit — the actor serializes everything else — so reading
7285
+ * it is reading your own writes, which is the answer the caller is owed and the one it
7286
+ * had. `ScopeActor.turn` makes the same call for the writers (#1678).
7287
+ */
7288
+ scopeReadDbFor(tenantId, scopeId) {
7289
+ this.assertScopeReachable(tenantId, scopeId);
7290
+ return this.readDb(this.runtime(tenantId, scopeId));
7291
+ }
7292
+ /**
7293
+ * `scopeReadDbFor` without the directory gate, for the host-side reads that already
7294
+ * resolved their runtime — the platform-intent drain's `listPlatformRequests` among them,
7295
+ * where a stranger's uncommitted intent was a platform effect about to be carried out for
7296
+ * an operation that then rolled back.
7297
+ */
7298
+ readDb(rt) {
7299
+ if (rt.actor.holds())
7300
+ return rt.db;
7301
+ const key = `${rt.tenantId}/${rt.scopeId}`;
7302
+ if (rt.reader) {
7303
+ // Most recently used goes to the back of the insertion order, which is the cap's LRU.
7304
+ this.readers.delete(key);
7305
+ this.readers.set(key, rt);
7306
+ return rt.reader;
7307
+ }
7308
+ // `runtime()` opened the writer first, in WAL, so the file and its `-shm` exist.
7309
+ rt.reader = new Database(rt.file, { readonly: true, fileMustExist: true });
7310
+ this.readers.set(key, rt);
7311
+ // The cap: a lake drain sweeps every scope, and without it every open scope would end
7312
+ // up holding two connections. The oldest reader is closed; the next read reopens it.
7313
+ while (this.readers.size > READER_CAP) {
7314
+ const [oldest, victim] = this.readers.entries().next().value;
7315
+ this.readers.delete(oldest);
7316
+ victim.reader?.close();
7317
+ victim.reader = null;
7318
+ }
7319
+ return rt.reader;
7320
+ }
7321
+ /**
7322
+ * Close a scope's connections and forget its runtime — the reader with the writer, so
7323
+ * neither outlives the file a reap or a snapshot delete is about to remove.
7324
+ */
7325
+ closeRuntime(rt) {
7326
+ const key = `${rt.tenantId}/${rt.scopeId}`;
7327
+ rt.reader?.close();
7328
+ rt.reader = null;
7329
+ this.readers.delete(key);
7330
+ rt.db.close();
7331
+ this.scopes.delete(key);
7332
+ this.scopesById.delete(rt.scopeId);
7333
+ }
6501
7334
  /**
6502
7335
  * The additive spine-column migrations, shared by `runtime()` and the dump replay.
6503
7336
  * KERNEL_DDL is all IF NOT EXISTS, so a scope DB created before a column keeps the
@@ -6516,6 +7349,11 @@ export class SqliteScopeHost {
6516
7349
  // consumer dead-letter.
6517
7350
  this.ensureColumn(db, '_substrat_deliveries', 'attempts', 'attempts INTEGER NOT NULL DEFAULT 0');
6518
7351
  this.ensureColumn(db, '_substrat_deliveries', 'next_attempt_at', 'next_attempt_at TEXT');
7352
+ // #1525: the invocation an attempt ran in, on a scope DB created before the column.
7353
+ // Nullable, and the null is honestly "no call was carried" — which attempt produced
7354
+ // a delivery already journalled cannot be decided afterwards, exactly as #1237's
7355
+ // outbox column argued.
7356
+ this.ensureColumn(db, '_substrat_deliveries', 'invocation_id', 'invocation_id TEXT');
6519
7357
  // K-34: the authorization column on a scope DB created before it existed. Nullable,
6520
7358
  // so legacy outbox rows read as "unrecorded" — the honest value. (_substrat_denials
6521
7359
  // is a whole new table, so KERNEL_DDL's IF NOT EXISTS covers it with no ALTER.)
@@ -6531,6 +7369,10 @@ export class SqliteScopeHost {
6531
7369
  this.ensureColumn(db, '_substrat_outbox', 'impersonation', 'impersonation TEXT');
6532
7370
  this.ensureColumn(db, '_substrat_platform_requests', 'impersonation', 'impersonation TEXT');
6533
7371
  this.ensureColumn(db, '_substrat_denials', 'impersonation', 'impersonation TEXT');
7372
+ // #1525: the invocation a refusal happened during, on a scope DB created before the
7373
+ // column. Nullable, and the null is honestly "no id was carried" — a past denial's
7374
+ // call cannot be decided afterwards, exactly as #1237's outbox column argued.
7375
+ this.ensureColumn(db, '_substrat_denials', 'invocation_id', 'invocation_id TEXT');
6534
7376
  // #1231: the emitting operation, on a scope DB created before the column. Nullable
6535
7377
  // so every legacy row reads as unrecorded rather than claiming a name nobody stamped.
6536
7378
  this.ensureColumn(db, '_substrat_outbox', 'operation', 'operation TEXT');
@@ -6544,6 +7386,9 @@ export class SqliteScopeHost {
6544
7386
  // #1237: existing scopes get it on next wake. Rows already written keep NULL, which
6545
7387
  // honestly means "no invocation id was carried" — nothing can decide one afterwards.
6546
7388
  this.ensureColumn(db, '_substrat_outbox', 'invocation_id', 'invocation_id TEXT');
7389
+ // #1288: not an ensureColumn, because `kind` joins the schedule-state PRIMARY KEY
7390
+ // and no ALTER can widen a key. Rebuilt instead, from the kernel's statements.
7391
+ this.ensureScheduleStateKind(db);
6547
7392
  // #1237: `readInvocation`'s lookup — WHERE invocation_id = ? ORDER BY id — over an outbox
6548
7393
  // that is never pruned. No index leads with invocation_id, so without this one SQLite
6549
7394
  // walks the PRIMARY KEY from the oldest event until it reaches the call, and reading a
@@ -6563,7 +7408,10 @@ export class SqliteScopeHost {
6563
7408
  const existing = this.scopes.get(key);
6564
7409
  if (existing)
6565
7410
  return existing;
6566
- const db = new Database(join(this.dir, `${tenantId}__${scopeId}.sqlite`));
7411
+ const file = join(this.dir, `${tenantId}__${scopeId}.sqlite`);
7412
+ const db = new Database(file);
7413
+ // WAL is also what the read connection rests on (#1624): a reader sees the last
7414
+ // committed snapshot and never blocks, or is blocked by, the writer.
6567
7415
  db.pragma('journal_mode = WAL');
6568
7416
  db.exec(KERNEL_DDL);
6569
7417
  this.ensureSpineColumns(db);
@@ -6580,7 +7428,9 @@ export class SqliteScopeHost {
6580
7428
  const created = {
6581
7429
  tenantId,
6582
7430
  scopeId,
7431
+ file,
6583
7432
  db,
7433
+ reader: null,
6584
7434
  actor: new ScopeActor(),
6585
7435
  appliedMigrations,
6586
7436
  mintEventId,
@@ -6611,6 +7461,20 @@ function cellToJson(v) {
6611
7461
  * `_substrat_*`" a mechanism rather than a lint rule (#954) — the kernel's own
6612
7462
  * spine writes use `rt.db` directly and never pass through here.
6613
7463
  */
7464
+ /**
7465
+ * The kernel's OWN spine access (#1672) — the same seam as `scopedSql` without `guardSpine`,
7466
+ * because these are the kernel's writes to `_substrat_capabilities`, which module code may
7467
+ * never make. Never handed to module code.
7468
+ */
7469
+ function spineSql(db) {
7470
+ return {
7471
+ query: (sql, params = []) => db.prepare(sql).all(...params),
7472
+ exec: (sql, params = []) => {
7473
+ const info = db.prepare(sql).run(...params);
7474
+ return { changes: info.changes };
7475
+ },
7476
+ };
7477
+ }
6614
7478
  function scopedSql(db) {
6615
7479
  return guardSpine({
6616
7480
  query: (sql, params = []) => db.prepare(sql).all(...params),