@substrat-run/adapter-sqlite 0.116.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, 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 { 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, 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, } 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
  *
@@ -130,6 +145,10 @@ const KERNEL_DDL = `
130
145
  drained_at TEXT
131
146
  );
132
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}
133
152
  -- #383 / #1232 / #1288: the platform sweep's per-scope gating state, holding two
134
153
  -- families of row that the kind COLUMN — not the spelling of a key — tells apart.
135
154
  -- Spine (kernel-written), never a module migration. Shared with the DO adapter from
@@ -330,23 +349,12 @@ function modelUsageEntryOf(r) {
330
349
  elapsedMs: r.elapsed_ms,
331
350
  });
332
351
  }
333
- /** Map a stored platform-request row to the `PlatformRequest` contract shape (JSON columns parsed). */
334
- function rowToPlatformRequest(r) {
335
- return platformRequest.parse({
336
- id: r.id,
337
- kind: r.kind,
338
- payload: JSON.parse(r.payload),
339
- requestedBy: JSON.parse(r.requested_by),
340
- impersonation: r.impersonation == null ? null : JSON.parse(r.impersonation),
341
- status: r.status,
342
- attempts: r.attempts,
343
- lastError: r.last_error,
344
- failure: r.last_failure == null ? null : JSON.parse(r.last_failure),
345
- result: r.result === null ? null : JSON.parse(r.result),
346
- requestedAt: r.requested_at,
347
- settledAt: r.settled_at,
348
- });
349
- }
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 };
350
358
  /**
351
359
  * The dispatch capability (#726 remedy B): a connector delivery may read the attachments
352
360
  * of the entity the DELIVERED EVENT names, and nothing else.
@@ -417,6 +425,8 @@ export class SqliteScopeHost {
417
425
  directory;
418
426
  scopes = new Map();
419
427
  scopesById = new Map();
428
+ /** The runtimes holding an open `reader` (#1624), least recently read first — `READER_CAP`'s LRU. */
429
+ readers = new Map();
420
430
  /** Per-tenant relational stores (#301), opened lazily and cached by `ref` (the bare
421
431
  * `.sqlite` filename). The pure-adapter analogue of a per-tenant D1 — one file per
422
432
  * (tenant, vertical, binding), physically separate from the scope DBs. */
@@ -1048,7 +1058,9 @@ export class SqliteScopeHost {
1048
1058
  at TEXT NOT NULL
1049
1059
  );
1050
1060
  CREATE INDEX IF NOT EXISTS _substrat_sweep_runs_unit ON _substrat_sweep_runs (kind, unit, id);
1051
- 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}
1052
1064
  CREATE INDEX IF NOT EXISTS _substrat_sweep_runs_tenant ON _substrat_sweep_runs (tenant_id, id);
1053
1065
  CREATE INDEX IF NOT EXISTS _substrat_sweep_runs_at ON _substrat_sweep_runs (at);
1054
1066
  -- Model usage (#1054, meter 3): one line per model call a vertical made through
@@ -1436,21 +1448,29 @@ export class SqliteScopeHost {
1436
1448
  await this.applyPendingMigrations(rt);
1437
1449
  // Project each registered module's SCHEDULE grants (#383): a system principal
1438
1450
  // holds exactly the permissions its schedules declared, on this scope. This is
1439
- // what makes `ctx.check` resolve for scheduled work — the gate stays the check,
1440
- // and revoking the tuple (console) is how scheduling is turned off per scope.
1441
- // Idempotent (INSERT OR REPLACE), so a re-provision re-asserts the same grants.
1442
- for (const mod of this.modules.values()) {
1443
- const perms = new Set();
1444
- for (const s of mod.schedules)
1445
- for (const p of s.permissions)
1446
- perms.add(p);
1447
- for (const perm of perms) {
1448
- rt.db
1449
- .prepare(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at)
1450
- VALUES (?, ?, ?, NULL)`)
1451
- .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
+ }
1452
1472
  }
1453
- }
1473
+ });
1454
1474
  // Audit a real provision only; an idempotent re-provision changed nothing.
1455
1475
  if (!existing) {
1456
1476
  this.recordAdmin(actor, 'provisionScope', { tenantId: input.tenantId, scopeId: input.scopeId, vertical: record.vertical }, null, record);
@@ -1857,12 +1877,12 @@ export class SqliteScopeHost {
1857
1877
  forkedFrom: input.forkedFrom ?? dump.scopeId,
1858
1878
  forkedAt: input.forkedAt ?? dump.capturedAt,
1859
1879
  });
1860
- this.loadDump(input.tenantId, input.scopeId, dump.tables);
1880
+ await this.loadDump(input.tenantId, input.scopeId, dump.tables);
1861
1881
  await this.admin.activateScope(actor, input.tenantId, input.scopeId);
1862
1882
  this.recordAdmin(actor, 'importScope', { tenantId: input.tenantId, scopeId: input.scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
1863
1883
  }
1864
1884
  /** Drop-then-replay a dump into a scope's db, refreshing the migration frontier. */
1865
- loadDump(tenantId, scopeId, tables) {
1885
+ async loadDump(tenantId, scopeId, tables) {
1866
1886
  const rt = this.runtime(tenantId, scopeId);
1867
1887
  const db = rt.db;
1868
1888
  const load = db.transaction((dumped) => {
@@ -1946,20 +1966,25 @@ export class SqliteScopeHost {
1946
1966
  db.prepare(`UPDATE OR REPLACE _substrat_tuples SET object = ?
1947
1967
  WHERE object LIKE 'scope:%' AND object <> ?`).run(`scope:${scopeId}`, `scope:${scopeId}`);
1948
1968
  });
1949
- load(tables);
1950
- // The frontier came in with the dump — refresh the cached applied-migration set so
1951
- // a later bind/migrate builds on the loaded state, not the previous one.
1952
- rt.appliedMigrations.clear();
1953
- for (const r of db.prepare('SELECT module_id, version FROM _substrat_migrations').all()) {
1954
- rt.appliedMigrations.add(`${r.module_id}@${r.version}`);
1955
- }
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
+ });
1956
1981
  }
1957
1982
  async restoreScope(actor, tenantId, scopeId, dump) {
1958
1983
  // Restore never creates a scope (that is importScope) — an unknown target fails closed.
1959
1984
  const existing = await this.admin.getScopeRecord(actor, tenantId, scopeId);
1960
1985
  if (!existing)
1961
1986
  throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
1962
- this.loadDump(tenantId, scopeId, dump.tables);
1987
+ await this.loadDump(tenantId, scopeId, dump.tables);
1963
1988
  this.recordAdmin(actor, 'restoreScope', { tenantId, scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
1964
1989
  }
1965
1990
  async snapshotScope(actor, tenantId, scopeId, opts) {
@@ -1996,13 +2021,9 @@ export class SqliteScopeHost {
1996
2021
  `archive a primary scope instead`);
1997
2022
  }
1998
2023
  // Close and evict the runtime handle before touching the file.
1999
- const key = `${tenantId}/${scopeId}`;
2000
- const rt = this.scopes.get(key);
2001
- if (rt) {
2002
- rt.db.close();
2003
- this.scopes.delete(key);
2004
- this.scopesById.delete(scopeId);
2005
- }
2024
+ const rt = this.scopes.get(`${tenantId}/${scopeId}`);
2025
+ if (rt)
2026
+ this.closeRuntime(rt);
2006
2027
  // Order is the retry story: hostnames first (a reaped preview URL must stop
2007
2028
  // resolving), then the STORAGE, then the directory row — so a crash mid-way leaves
2008
2029
  // a visible row over empty storage (re-running deleteSnapshot converges), never
@@ -2180,6 +2201,82 @@ export class SqliteScopeHost {
2180
2201
  await this.applyPendingMigrations(rt);
2181
2202
  return this.buildStub(tenantId, scopeId, rt, { kind: 'system', id: moduleId });
2182
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
+ }
2183
2280
  registeredSchedules() {
2184
2281
  const out = [];
2185
2282
  for (const mod of this.modules.values()) {
@@ -2241,45 +2338,51 @@ export class SqliteScopeHost {
2241
2338
  }
2242
2339
  if (windows.size === 0)
2243
2340
  return report;
2244
- const types = [...windows.keys()];
2245
- const observed = new Map();
2246
- for (const row of rt.db
2247
- .prepare(`SELECT type, MAX(occurred_at) AS at FROM _substrat_outbox
2248
- WHERE type IN (${types.map(() => '?').join(', ')}) GROUP BY type`)
2249
- .all(...types)) {
2250
- if (row.at !== null)
2251
- observed.set(row.type, row.at);
2252
- }
2253
- const nowIso = this.clock();
2254
- const now = Date.parse(nowIso);
2255
- for (const [eventType, withinHours] of windows) {
2256
- const observedAt = observed.get(eventType) ?? null;
2257
- const outcome = observedAt === null
2258
- ? 'skipped' // never observed — the never-run analogue, not a failure
2259
- : now - Date.parse(observedAt) <= withinHours * 3_600_000
2260
- ? 'ok'
2261
- : 'failed';
2262
- const stateKey = `freshness:${eventType}`;
2263
- // #1288: the evaluator's own row, named by kind. Before the column, a module
2264
- // declaring a schedule called `freshness:<this type>` shared it — and the
2265
- // verdict read back here was whichever of the two wrote last.
2266
- const state = rt.db
2267
- .prepare(`SELECT last_run_at, last_status FROM _substrat_schedule_state
2268
- WHERE kind = 'freshness' AND schedule_op = ?`)
2269
- .get(stateKey);
2270
- const changed = state?.last_status !== outcome;
2271
- const heartbeatDue = !state?.last_run_at ||
2272
- now - Date.parse(state.last_run_at) > FRESHNESS_HEARTBEAT_MINUTES * 60_000;
2273
- if (!changed && !heartbeatDue)
2274
- continue;
2275
- rt.db
2276
- .prepare(`INSERT INTO _substrat_schedule_state (kind, schedule_op, last_run_at, last_status)
2277
- VALUES ('freshness', ?, ?, ?)
2278
- ON CONFLICT(kind, schedule_op) DO UPDATE SET last_run_at = excluded.last_run_at,
2279
- last_status = excluded.last_status`)
2280
- .run(stateKey, nowIso, outcome);
2281
- report.checks.push({ eventType, outcome, observedAt, withinHours });
2282
- }
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
+ });
2283
2386
  return report;
2284
2387
  }
2285
2388
  async runDueSchedules(moduleId, tenantId, scopeId) {
@@ -2298,18 +2401,48 @@ export class SqliteScopeHost {
2298
2401
  return report;
2299
2402
  const rt = this.runtime(tenantId, scopeId);
2300
2403
  await this.applyPendingMigrations(rt);
2301
- // The grant IS the switch (#383): a scope runs a module's schedules only while it
2302
- // holds a live `system:<moduleId>` grant. This is what makes a foreign-vertical
2303
- // scope (one this module was never provisioned on) a quiet no-op, and what makes
2304
- // "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.
2305
2409
  const nowIso = this.clock();
2306
- const hasGrant = rt.db
2307
- .prepare(`SELECT 1 FROM _substrat_tuples
2308
- WHERE subject = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)
2309
- LIMIT 1`)
2310
- .get(`system:${moduleId}`, nowIso);
2311
- 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')
2312
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;
2444
+ return report;
2445
+ }
2313
2446
  // The spine state table is KERNEL_DDL's (#1288) — kernel-owned (`_substrat_*`),
2314
2447
  // never a module migration, and created by `runtime()` above rather than lazily
2315
2448
  // here. This loop's rows are `kind = 'schedule'`, keyed by the operation
@@ -2318,14 +2451,8 @@ export class SqliteScopeHost {
2318
2451
  // for what the same two columns mean there.
2319
2452
  const now = Date.parse(nowIso);
2320
2453
  for (const schedule of mod.schedules) {
2321
- // #1288: this schedule's own row. `kind` is what makes that true even when the
2322
- // operation is spelled `freshness:<eventType>` — without it, the cadence gate
2323
- // below read the evaluator's last recorded verdict as a run of this operation.
2324
- const row = rt.db
2325
- .prepare(`SELECT last_run_at FROM _substrat_schedule_state
2326
- WHERE kind = 'schedule' AND schedule_op = ?`)
2327
- .get(schedule.operation);
2328
- 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;
2329
2456
  const dueAt = lastRun === null ? -Infinity : lastRun + schedule.cadence.everyMinutes * 60_000;
2330
2457
  if (now < dueAt) {
2331
2458
  report.skipped += 1;
@@ -2348,18 +2475,27 @@ export class SqliteScopeHost {
2348
2475
  error: err instanceof Error ? err.message : String(err),
2349
2476
  });
2350
2477
  }
2351
- rt.db
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
2352
2481
  .prepare(`INSERT INTO _substrat_schedule_state (kind, schedule_op, last_run_at, last_status)
2353
- VALUES ('schedule', ?, ?, ?)
2354
- ON CONFLICT(kind, schedule_op) DO UPDATE SET last_run_at = excluded.last_run_at,
2355
- last_status = excluded.last_status`)
2356
- .run(schedule.operation, new Date(now).toISOString(), 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));
2357
2486
  report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
2358
2487
  }
2359
2488
  return report;
2360
2489
  }
2361
- /** The stub body, shared by the principal, connection, system and impersonation doors. */
2362
- 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,
2363
2499
  /**
2364
2500
  * K-42: the session this stub acts under. `subject` is already the
2365
2501
  * IMPERSONATED principal — that is what makes the permission model answer
@@ -2441,9 +2577,22 @@ export class SqliteScopeHost {
2441
2577
  // which is the leak `causedBy` had to be moved off the host to avoid.
2442
2578
  rt.invocationId = invokeOptions?.invocationId ?? null;
2443
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 = [];
2444
2593
  // Fresh per operation: the context carries the K-34 authorization accumulator,
2445
2594
  // which must not leak across operations (invokes are serialized per scope).
2446
- const ctx = this.operationContext(rt, subject, undefined, signals, session, operation);
2595
+ const ctx = this.operationContext(rt, subject, undefined, signals, session, operation, minted);
2447
2596
  const clonedInput = structuredClone(input);
2448
2597
  // #893: parse, don't trust — at the scope door, from the operation's own
2449
2598
  // declaration. BEFORE `BEGIN`, so a malformed call never opens a
@@ -2529,7 +2678,9 @@ export class SqliteScopeHost {
2529
2678
  // that adds a row.
2530
2679
  if (idempotencyKey !== undefined && fingerprint !== undefined) {
2531
2680
  const at = this.clock();
2532
- 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);
2533
2684
  rt.db.prepare(record.sql).run(...record.params);
2534
2685
  const prune = idempotencyPruneStatement(at);
2535
2686
  rt.db.prepare(prune.sql).run(...prune.params);
@@ -2635,10 +2786,8 @@ export class SqliteScopeHost {
2635
2786
  }
2636
2787
  }
2637
2788
  async close() {
2638
- for (const { db } of this.scopes.values())
2639
- db.close();
2640
- this.scopes.clear();
2641
- this.scopesById.clear();
2789
+ for (const rt of [...this.scopes.values()])
2790
+ this.closeRuntime(rt);
2642
2791
  this.directory.close();
2643
2792
  }
2644
2793
  // -------------------------------------------------------------------------
@@ -2696,8 +2845,22 @@ export class SqliteScopeHost {
2696
2845
  ORDER BY o.id`)
2697
2846
  .all(deliveryId, executor.eventType, now);
2698
2847
  for (const row of rows) {
2699
- const event = this.parseOutboxRow(row);
2700
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
+ }
2701
2864
  this.causedBy = event.id;
2702
2865
  try {
2703
2866
  if (executor.kind === 'connector') {
@@ -2936,7 +3099,7 @@ export class SqliteScopeHost {
2936
3099
  // "not granted" where the checker answers allow — a read-back that disagrees with
2937
3100
  // enforcement is worse than none, since it is the read an operator would trust.
2938
3101
  const rows = [
2939
- ...rt.db
3102
+ ...this.readDb(rt)
2940
3103
  .prepare(`SELECT subject, relation, expires_at FROM _substrat_tuples
2941
3104
  WHERE subject LIKE 'connection:%' AND relation LIKE 'granted:%'
2942
3105
  AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)`)
@@ -3016,7 +3179,7 @@ export class SqliteScopeHost {
3016
3179
  async executorDeadLetters(tenantId, scopeId) {
3017
3180
  const rt = this.runtime(tenantId, scopeId);
3018
3181
  await this.applyPendingMigrations(rt);
3019
- const rows = rt.db
3182
+ const rows = this.readDb(rt)
3020
3183
  .prepare(`SELECT d.event_id, d.consumer_module, d.attempts, d.error, d.delivered_at, o.type
3021
3184
  FROM _substrat_deliveries d
3022
3185
  JOIN _substrat_outbox o ON o.id = d.event_id
@@ -3037,27 +3200,36 @@ export class SqliteScopeHost {
3037
3200
  async listPlatformRequests(tenantId, scopeId) {
3038
3201
  const rt = this.runtime(tenantId, scopeId);
3039
3202
  await this.applyPendingMigrations(rt);
3040
- const rows = rt.db
3203
+ const rows = this.readDb(rt)
3041
3204
  .prepare(`SELECT ${PLATFORM_REQUEST_COLUMNS}
3042
3205
  FROM _substrat_platform_requests WHERE status = 'pending' ORDER BY id`)
3043
3206
  .all();
3044
- return rows.map(rowToPlatformRequest);
3207
+ // Tolerant (#1588): one undecodable row comes back naming why, never throws for the list.
3208
+ return rows.map(platformRequestOf);
3045
3209
  }
3046
3210
  async listPlatformRequestHistory(tenantId, scopeId, filter) {
3047
3211
  const rt = this.runtime(tenantId, scopeId);
3048
3212
  await this.applyPendingMigrations(rt);
3049
3213
  const q = platformRequestHistoryQuery(filter);
3050
- return rt.db.prepare(q.sql).all(...q.params).map(rowToPlatformRequest);
3214
+ return this.readDb(rt).prepare(q.sql).all(...q.params).map(platformRequestOf);
3051
3215
  }
3052
3216
  async settlePlatformRequest(tenantId, scopeId, id, outcome) {
3053
3217
  const rt = this.runtime(tenantId, scopeId);
3054
3218
  await this.applyPendingMigrations(rt);
3055
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.
3056
3228
  rt.db
3057
3229
  .prepare(`UPDATE _substrat_platform_requests
3058
3230
  SET status = ?, result = COALESCE(?, result), last_error = ?, last_failure = ?,
3059
3231
  attempts = attempts + 1, settled_at = ?
3060
- WHERE id = ?`)
3232
+ WHERE id = ? AND status = 'pending'`)
3061
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);
3062
3234
  });
3063
3235
  }
@@ -3082,7 +3254,24 @@ export class SqliteScopeHost {
3082
3254
  ORDER BY o.id`)
3083
3255
  .all(consumer.eventType, mod.id);
3084
3256
  for (const row of rows) {
3085
- 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
+ }
3086
3275
  const ctx = this.operationContext(rt, asPrincipal(this.systemPrincipal), {
3087
3276
  system: mod.id,
3088
3277
  });
@@ -3152,11 +3341,7 @@ export class SqliteScopeHost {
3152
3341
  // policy, carries no permission key, and is left to the module.
3153
3342
  if (!err.permission || !err.node)
3154
3343
  return;
3155
- const actor = subject.kind === 'system'
3156
- ? { system: subject.id }
3157
- : subject.kind === 'connection'
3158
- ? { connection: subject.id }
3159
- : subject.id;
3344
+ const actor = actorOf(subject);
3160
3345
  rt.db
3161
3346
  .prepare(`INSERT INTO _substrat_denials
3162
3347
  (id, actor, permission, tenant_id, scope_id, operation, impersonation,
@@ -3166,29 +3351,6 @@ export class SqliteScopeHost {
3166
3351
  // #1525: the call this refusal belongs to, as the caller named it.
3167
3352
  invocationId, new Date().toISOString());
3168
3353
  }
3169
- parseOutboxRow(row) {
3170
- return domainEvent.parse({
3171
- id: row.id,
3172
- type: row.type,
3173
- schemaVersion: row.schema_version,
3174
- occurredAt: row.occurred_at,
3175
- tenantId: row.tenant_id,
3176
- scopeId: row.scope_id,
3177
- actor: JSON.parse(row.actor),
3178
- entity: { entityType: row.entity_type, entityId: row.entity_id },
3179
- piiClass: row.pii_class,
3180
- ...(row.subject_id ? { subjectId: row.subject_id } : {}),
3181
- ...(row.authorization ? { authorization: JSON.parse(row.authorization) } : {}),
3182
- // K-42: the stamp survives the read, so a consumer's event and an executor's
3183
- // are the same fact the stored row is. Absent rather than null when nobody
3184
- // was impersonating, because `DomainEvent.impersonation` is optional — the
3185
- // shape module code never sees is also the shape it cannot branch on.
3186
- ...(row.impersonation ? { impersonation: JSON.parse(row.impersonation) } : {}),
3187
- // #1231: absent rather than null, the same shape rule as the stamp above.
3188
- ...(row.operation ? { operation: row.operation } : {}),
3189
- payload: row.payload === null ? undefined : JSON.parse(row.payload),
3190
- });
3191
- }
3192
3354
  // -------------------------------------------------------------------------
3193
3355
  // Admin surface (enforcement input, §9.2.5)
3194
3356
  // -------------------------------------------------------------------------
@@ -3615,6 +3777,9 @@ export class SqliteScopeHost {
3615
3777
  (tenant_id, subject, relation, object, expires_at)
3616
3778
  VALUES (?, ?, ?, ?, ?)`)
3617
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.
3618
3783
  const writeScopeTuple = (node, subject, relation, object, expiresAt) => {
3619
3784
  if (!node.scopeId)
3620
3785
  throw new Error('scope tuple requires node.scopeId');
@@ -3635,6 +3800,82 @@ export class SqliteScopeHost {
3635
3800
  writeTenantTuple(node.tenantId, subject, `granted:${permission}`, `tenant:${node.tenantId}`, expiresAt);
3636
3801
  }
3637
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
+ };
3638
3879
  return {
3639
3880
  // #603: fixed at construction — a host built without a box can never store a
3640
3881
  // credential, and saying so is what lets a transport answer 503 instead of 500.
@@ -3693,7 +3934,8 @@ export class SqliteScopeHost {
3693
3934
  assignRole: async (actor, assignment) => {
3694
3935
  const subject = `principal:${assignment.principalId}`;
3695
3936
  if (assignment.node.scopeId) {
3696
- 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}`));
3697
3939
  }
3698
3940
  else {
3699
3941
  writeTenantTuple(assignment.node.tenantId, subject, `role:${assignment.roleKey}`, `tenant:${assignment.node.tenantId}`);
@@ -3709,11 +3951,14 @@ export class SqliteScopeHost {
3709
3951
  const now = new Date().toISOString();
3710
3952
  let changes;
3711
3953
  if (assignment.node.scopeId) {
3712
- const rt = this.runtime(assignment.node.tenantId, assignment.node.scopeId);
3713
- 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
3714
3959
  .prepare(`UPDATE _substrat_tuples SET revoked_at = ?
3715
- WHERE subject = ? AND relation = ? AND object = ? AND revoked_at IS NULL`)
3716
- .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);
3717
3962
  }
3718
3963
  else {
3719
3964
  changes = this.directory
@@ -3730,44 +3975,56 @@ export class SqliteScopeHost {
3730
3975
  // trust: the parse is where an `expiresAt` carrying a UTC offset is normalised
3731
3976
  // to Z text (#963), and liveness here is a lexicographic `expires_at > ?`.
3732
3977
  const grant = capabilityGrant.parse(raw);
3733
- 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));
3734
3979
  this.recordAdmin(actor, 'grant', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, grant);
3735
3980
  },
3736
3981
  grantToConnection: async (actor, raw) => {
3737
3982
  const grant = connectionGrant.parse(raw);
3738
- const conn = this.connectionRow(grant.connectionId);
3739
- if (conn.revoked_at) {
3740
- throw new Error(`connection ${grant.connectionId} is revoked — grant nothing to it`);
3741
- }
3742
- // The grant may not reach outside what the connection already is. A
3743
- // connection is keyed (tenant, vertical, provider); letting it hold a
3744
- // permission in another tenant would make the key decorative.
3745
- if (conn.tenant_id !== grant.node.tenantId) {
3746
- throw new Error(`connection ${grant.connectionId} belongs to tenant ${conn.tenant_id} and cannot ` +
3747
- `be granted anything in ${grant.node.tenantId}`);
3748
- }
3749
- if (grant.node.scopeId) {
3750
- const scope = this.directory
3751
- .prepare('SELECT tenant_id, vertical FROM scopes WHERE scope_id = ?')
3752
- .get(grant.node.scopeId);
3753
- if (!scope || scope.tenant_id !== grant.node.tenantId) {
3754
- 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`);
3755
3987
  }
3756
- if (scope.vertical !== conn.vertical) {
3757
- throw new Error(`connection ${grant.connectionId} is for vertical '${conn.vertical}' and scope ` +
3758
- `${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}`);
3759
3994
  }
3760
- }
3761
- writeGrant(subjectRef({ kind: 'connection', id: grant.connectionId }), grant.permission, grant.node, undefined, grant.expiresAt);
3762
- // #592: the directory-side record, alongside the tuple. The tuple is checked
3763
- // where it lives; this row is what provision/reconcile gather FROM, so the
3764
- // grant reaches scopes provisioned after it without a human replaying it.
3765
- this.directory
3766
- .prepare(`INSERT OR REPLACE INTO _substrat_connection_grants
3767
- (connection_id, tenant_id, vertical, permission, scope_id, expires_at,
3768
- granted_by, granted_at, revoked_at)
3769
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`)
3770
- .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
+ });
3771
4028
  this.recordAdmin(actor, 'grantToConnection', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId, vertical: conn.vertical }, null, {
3772
4029
  connectionId: grant.connectionId,
3773
4030
  provider: conn.provider,
@@ -3783,15 +4040,79 @@ export class SqliteScopeHost {
3783
4040
  // principal is bound to the module, and a scope only ever runs the modules
3784
4041
  // its own host registered.
3785
4042
  const grant = systemGrant.parse(raw);
3786
- 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
+ }
3787
4064
  this.recordAdmin(actor, 'grantToSystem', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, { moduleId: grant.moduleId, permission: grant.permission, node: grant.node });
3788
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
+ },
3789
4105
  grantToOrg: async (actor, orgId, permission, node, entity) => {
3790
4106
  // The org must exist in the node's tenant. A grant to a phantom org is
3791
4107
  // worse than an error: it looks applied, resolves for nobody, and shows up
3792
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).
3793
4111
  requireOrg(node.tenantId, orgId);
3794
- writeGrant(`org:${orgId}`, permission, node, entity);
4112
+ await onNode(node, () => {
4113
+ requireOrg(node.tenantId, orgId);
4114
+ writeGrant(`org:${orgId}`, permission, node, entity);
4115
+ });
3795
4116
  this.recordAdmin(actor, 'grantToOrg', { tenantId: node.tenantId, scopeId: node.scopeId }, null, { orgId, permission, node, entity });
3796
4117
  },
3797
4118
  // -- vertical + version registry (#31) ---------------------------------
@@ -4452,24 +4773,18 @@ export class SqliteScopeHost {
4452
4773
  this.recordAdmin(actor, 'setScopeExpiresAt', { tenantId, scopeId }, { expiresAt: scope.expires_at }, { expiresAt });
4453
4774
  },
4454
4775
  readUndrainedEvents: async (actor, tenantId, scopeId, limit) => {
4455
- const db = this.scopeDbFor(tenantId, scopeId);
4456
- const rows = db
4457
- .prepare(`SELECT * FROM _substrat_outbox WHERE drained_at IS NULL ORDER BY id LIMIT ?`)
4458
- .all(Math.min(Math.max(limit ?? 200, 1), 1000));
4459
- this.recordAccess(actor, 'readUndrainedEvents', { tenantId, scopeId }, { limit }, rows.length);
4460
- return rows.map((r) => ({
4461
- ...this.parseOutboxRow(r),
4462
- operation: r.operation ?? null,
4463
- version: r.version ?? null,
4464
- // #1237 — lifted like the two above, and for the same reason: the column
4465
- // exists on the outbox but not on the envelope `parseOutboxRow` returns,
4466
- // whose `domainEvent.parse` strips anything it does not declare.
4467
- causedBy: r.caused_by ?? null,
4468
- // Same lift, same reason. `drainedEvent` requires this field, and the `as never`
4469
- // below is what let it be missed: without it a SQLite-backed lake loses the
4470
- // call grouping permanently, while a Cloudflare-backed one keeps it.
4471
- invocationId: r.invocation_id ?? null,
4472
- }));
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);
4473
4788
  },
4474
4789
  markEventsDrained: async (actor, tenantId, scopeId, eventIds) => {
4475
4790
  // BEFORE the empty-batch shortcut, not after. "Nothing to mark" and "you may
@@ -4490,12 +4805,16 @@ export class SqliteScopeHost {
4490
4805
  // cannot absorb: the next read resumes from the middle of a batch already
4491
4806
  // in the lake, and the object it writes overlaps the one already there —
4492
4807
  // the duplication the first-id object key exists to prevent.
4493
- 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(() => {
4494
4813
  let n = 0;
4495
4814
  for (const id of eventIds)
4496
4815
  n += stmt.run(at, id).changes;
4497
4816
  return n;
4498
- })();
4817
+ })());
4499
4818
  // K-24's rule, one tier down (#1334): declaring domain payloads shipped is an
4500
4819
  // EGRESS, and the admin log is where "these events left the platform, at this
4501
4820
  // time, on this actor's say-so" is recorded. `drainAccessLog` audits the same
@@ -4532,7 +4851,8 @@ export class SqliteScopeHost {
4532
4851
  // an access row — the count itself as the result count, which is the fact an
4533
4852
  // auditor asking "who counted this tenant's outbox, and how much was there" wants.
4534
4853
  if (countOnly) {
4535
- const redrainable = db
4854
+ // The committed count (#1624): a stranger's uncommitted drain mark is not a fact yet.
4855
+ const redrainable = this.scopeReadDbFor(tenantId, scopeId)
4536
4856
  .prepare(`SELECT COUNT(*) AS c FROM _substrat_outbox
4537
4857
  WHERE drained_at IS NOT NULL AND drained_at < ?`)
4538
4858
  .get(drainedBefore).c;
@@ -4556,14 +4876,17 @@ export class SqliteScopeHost {
4556
4876
  // is never pruned, so the unbounded form scales with the scope's whole history.
4557
4877
  // SQLite is built here without UPDATE...LIMIT, so the batch is a subselect over the
4558
4878
  // primary key — oldest first, so a partial run leaves a prefix rather than holes.
4559
- 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
4560
4883
  .prepare(`UPDATE _substrat_outbox SET drained_at = NULL
4561
- WHERE id IN (
4562
- SELECT id FROM _substrat_outbox
4563
- WHERE drained_at IS NOT NULL AND drained_at < ?
4564
- ORDER BY id LIMIT ?
4565
- )`)
4566
- .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);
4567
4890
  // The outcome beside the intent above — only when something changed, so a re-run
4568
4891
  // over an already reopened window leaves no row claiming it did something.
4569
4892
  if (redrained > 0) {
@@ -4574,7 +4897,7 @@ export class SqliteScopeHost {
4574
4897
  facetEvents: async (actor, tenantId, scopeId, input) => {
4575
4898
  // `facetEvents` is the sanctioned read: an erased payload yields the same
4576
4899
  // NULL a missing field does, and only the helper counts them apart.
4577
- const db = this.scopeDbFor(tenantId, scopeId);
4900
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4578
4901
  const result = facetEvents({ sql: scopedSql(db) }, input);
4579
4902
  this.recordAccess(actor, 'facetEvents', { tenantId, scopeId }, input, result.buckets.length);
4580
4903
  return result;
@@ -4584,7 +4907,7 @@ export class SqliteScopeHost {
4584
4907
  // only one that decodes the envelope's nullable facts (an erased payload, an
4585
4908
  // unrecorded authorization chain, nobody impersonating) as facts rather than
4586
4909
  // as missing data.
4587
- const db = this.scopeDbFor(tenantId, scopeId);
4910
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4588
4911
  const page = readHistory({ sql: scopedSql(db) }, { entityType: input.entityType, entityId: input.entityId }, { limit: input.limit, cursor: input.cursor });
4589
4912
  this.recordAccess(actor, 'entityHistory', { tenantId, scopeId }, { entityType: input.entityType, entityId: input.entityId }, page.entries.length);
4590
4913
  return page;
@@ -4593,7 +4916,7 @@ export class SqliteScopeHost {
4593
4916
  // #1237: the sanctioned walk. The helper owns the one distinction the whole
4594
4917
  // view rests on — a null cause with an operation is the beginning of the
4595
4918
  // chain, a null cause without one is the trail running out.
4596
- const db = this.scopeDbFor(tenantId, scopeId);
4919
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4597
4920
  const result = walkEventCause({ sql: scopedSql(db) }, input.eventId, input.maxDepth);
4598
4921
  this.recordAccess(actor, 'eventCause', { tenantId, scopeId }, { eventId: input.eventId }, result.chain.length);
4599
4922
  return result;
@@ -4601,27 +4924,27 @@ export class SqliteScopeHost {
4601
4924
  eventEffects: async (actor, tenantId, scopeId, input) => {
4602
4925
  // #1237 forward: the helper owns the two delivery traps — `delivered_at`
4603
4926
  // meaning two different things, and an empty list being ambiguous.
4604
- const db = this.scopeDbFor(tenantId, scopeId);
4927
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4605
4928
  const tree = walkEventEffects({ sql: scopedSql(db) }, input.eventId, input.maxNodes);
4606
4929
  this.recordAccess(actor, 'eventEffects', { tenantId, scopeId }, { eventId: input.eventId }, tree.count);
4607
4930
  return tree;
4608
4931
  },
4609
4932
  invocationEvents: async (actor, tenantId, scopeId, input) => {
4610
4933
  // #1237: one call's events, siblings included — what neither walk reaches.
4611
- const db = this.scopeDbFor(tenantId, scopeId);
4934
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4612
4935
  const read = readInvocation({ sql: scopedSql(db) }, input.invocationId, input.limit);
4613
4936
  this.recordAccess(actor, 'invocationEvents', { tenantId, scopeId }, { invocationId: input.invocationId }, read.events.length);
4614
4937
  return read;
4615
4938
  },
4616
4939
  deadLetters: async (actor, tenantId, scopeId, input) => {
4617
4940
  // #1525: every delivery that gave up, scope-wide — what the walks reach only by event.
4618
- const db = this.scopeDbFor(tenantId, scopeId);
4941
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4619
4942
  const page = readDeadLetters({ sql: scopedSql(db) }, { limit: input.limit, cursor: input.cursor });
4620
4943
  this.recordAccess(actor, 'deadLetters', { tenantId, scopeId }, null, page.entries.length);
4621
4944
  return page;
4622
4945
  },
4623
4946
  scopeAppliedMigrations: async (actor, tenantId, scopeId) => {
4624
- const db = this.scopeDbFor(tenantId, scopeId);
4947
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4625
4948
  const rows = db
4626
4949
  .prepare(`SELECT module_id, version, applied_at FROM _substrat_migrations
4627
4950
  ORDER BY applied_at DESC, module_id, version LIMIT 100`)
@@ -4645,6 +4968,16 @@ export class SqliteScopeHost {
4645
4968
  this.recordAccess(actor, 'scopeMigrationBookmarks', { tenantId, scopeId }, null, 0);
4646
4969
  return [];
4647
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
+ },
4648
4981
  rewindScope: async () => {
4649
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');
4650
4983
  },
@@ -4877,7 +5210,7 @@ export class SqliteScopeHost {
4877
5210
  listScopeTables: async (actor, tenantId, scopeId) => {
4878
5211
  // K-3: the (tenantId, scopeId) pair is cross-checked before we open anything;
4879
5212
  // a scope under a different tenant is unreachable, never another tenant's DB.
4880
- const db = this.scopeDbFor(tenantId, scopeId);
5213
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4881
5214
  const names = db
4882
5215
  .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`)
4883
5216
  .all().map((r) => r.name);
@@ -4890,7 +5223,7 @@ export class SqliteScopeHost {
4890
5223
  return tables;
4891
5224
  },
4892
5225
  readScopeTable: async (actor, tenantId, scopeId, input) => {
4893
- const db = this.scopeDbFor(tenantId, scopeId);
5226
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4894
5227
  // Validate the table against the LIVE schema — an unknown name throws, it is
4895
5228
  // never interpolated blind. That validated name is the only thing that reaches
4896
5229
  // the query, so the quoted identifier below carries no user input.
@@ -4907,7 +5240,7 @@ export class SqliteScopeHost {
4907
5240
  return { table: input.table, columns, rows, rowCount, limit, offset };
4908
5241
  },
4909
5242
  queryScope: async (actor, tenantId, scopeId, input) => {
4910
- const db = this.scopeDbFor(tenantId, scopeId);
5243
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4911
5244
  // Layer 1, shared: the kernel's textual gate (single statement, read verbs
4912
5245
  // only) — the checked statement is the one that runs.
4913
5246
  const sql = assertReadOnlyQuery(input.sql);
@@ -4942,14 +5275,14 @@ export class SqliteScopeHost {
4942
5275
  // read records to the K-24 access log. What differs is that these two answer a
4943
5276
  // question about the permission model rather than about the data.
4944
5277
  listDenials: async (actor, tenantId, scopeId, filter) => {
4945
- const db = this.scopeDbFor(tenantId, scopeId);
5278
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4946
5279
  const q = denialListQuery(filter);
4947
5280
  const rows = db.prepare(q.sql).all(...q.params).map(mapDenialRow);
4948
5281
  this.recordAccess(actor, 'listDenials', { tenantId, scopeId }, filter ?? null, rows.length);
4949
5282
  return rows;
4950
5283
  },
4951
5284
  summarizeDenials: async (actor, tenantId, scopeId, filter) => {
4952
- const db = this.scopeDbFor(tenantId, scopeId);
5285
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4953
5286
  const b = denialSummaryQuery(filter);
4954
5287
  // Grouped as the filter asked (#1456), and the answer says which way.
4955
5288
  const grouped = mapDenialSummaryBuckets(b.groupBy, db.prepare(b.sql).all(...b.params));
@@ -4969,7 +5302,7 @@ export class SqliteScopeHost {
4969
5302
  },
4970
5303
  exportScope: async (actor, tenantId, scopeId) => {
4971
5304
  // K-3: cross-check the pair before opening anything (same as the introspection reads).
4972
- const db = this.scopeDbFor(tenantId, scopeId);
5305
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4973
5306
  // Every real table, keeping the `_substrat_*` spine but dropping SQLite's own
4974
5307
  // `sqlite_*` internals — those are auto-managed and `CREATE TABLE sqlite_*` is
4975
5308
  // rejected on reload. `sql` is the CREATE statement, replayed to rebuild the schema.
@@ -5110,13 +5443,11 @@ export class SqliteScopeHost {
5110
5443
  throw new Error(`scope ${scopeId} still resolves hostname '${bound.hostname}' — ` +
5111
5444
  `unbind it before reaping (reap wipes storage and cannot be undone)`);
5112
5445
  }
5113
- const key = `${tenantId}/${scopeId}`;
5114
- const rt = this.scopes.get(key);
5115
- if (rt) {
5116
- rt.db.close();
5117
- this.scopes.delete(key);
5118
- this.scopesById.delete(scopeId);
5119
- }
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);
5120
5451
  rmSync(join(this.dir, `${tenantId}__${scopeId}.sqlite`), { force: true });
5121
5452
  // The recoverable copy the caller stored first (#493), named in the audit entry so
5122
5453
  // the trail answers "was there a backup" without correlating two timestamps.
@@ -5155,15 +5486,31 @@ export class SqliteScopeHost {
5155
5486
  // and transaction facts remain". A consumer's timeline still shows that something
5156
5487
  // happened, to what, and when; it no longer shows who or what was said.
5157
5488
  const db = this.scopeDbFor(tenantId, scopeId);
5158
- const redacted = db
5159
- .prepare(`UPDATE _substrat_outbox SET payload = NULL
5160
- WHERE subject_id = ? AND pii_class != 'none' AND payload IS NOT NULL`)
5161
- .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.
5162
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
+ }));
5163
5509
  const { existed } = await this.subjectKeysFor(tenantId, scopeId).destroy(subjectId, at);
5164
5510
  const receipt = subjectShredReceipt.parse({
5165
5511
  subjectId,
5166
5512
  eventsRedacted: redacted.changes,
5513
+ intentsRedacted,
5167
5514
  keyDestroyed: existed,
5168
5515
  tombstoned: true,
5169
5516
  });
@@ -5171,7 +5518,9 @@ export class SqliteScopeHost {
5171
5518
  // mutation, the access log because it destroys evidence. An erasure is the one
5172
5519
  // action where "who asked for this to disappear" is itself the record.
5173
5520
  this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
5174
- 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);
5175
5524
  return receipt;
5176
5525
  },
5177
5526
  // -- impersonation (K-42, #868) ----------------------------------------
@@ -6148,6 +6497,24 @@ export class SqliteScopeHost {
6148
6497
  // where the whole replay is already inside one.
6149
6498
  db.transaction(() => db.exec(SCHEDULE_STATE_REBUILD))();
6150
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
+ }
6151
6518
  /**
6152
6519
  * Rebuild `_substrat_identities` when it still carries the pre-K-22 global key.
6153
6520
  * A PRIMARY KEY cannot be ALTERed, so this is create-copy-drop-rename.
@@ -6168,7 +6535,9 @@ export class SqliteScopeHost {
6168
6535
  .get('_substrat_identities');
6169
6536
  if (!row || row.sql.includes('PRIMARY KEY (tenant_id, provider, external_id)'))
6170
6537
  return;
6171
- this.directory.exec(`
6538
+ // #1573: one transaction — see `rebuildAtomically`.
6539
+ this.rebuildAtomically(`
6540
+ DROP TABLE IF EXISTS _substrat_identities_new;
6172
6541
  CREATE TABLE _substrat_identities_new (
6173
6542
  provider TEXT NOT NULL,
6174
6543
  external_id TEXT NOT NULL,
@@ -6199,7 +6568,9 @@ export class SqliteScopeHost {
6199
6568
  .get('_substrat_admin_log');
6200
6569
  if (!row || !/tenant_id TEXT NOT NULL/.test(row.sql))
6201
6570
  return;
6202
- this.directory.exec(`
6571
+ // #1573: one transaction — see `rebuildAtomically`.
6572
+ this.rebuildAtomically(`
6573
+ DROP TABLE IF EXISTS _substrat_admin_log_new;
6203
6574
  CREATE TABLE _substrat_admin_log_new (
6204
6575
  id TEXT PRIMARY KEY,
6205
6576
  actor TEXT NOT NULL,
@@ -6218,6 +6589,30 @@ export class SqliteScopeHost {
6218
6589
  ALTER TABLE _substrat_admin_log_new RENAME TO _substrat_admin_log;
6219
6590
  `);
6220
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
+ }
6221
6616
  ensureDirectoryColumns() {
6222
6617
  this.ensureIdentityKey();
6223
6618
  this.ensureAdminLogTenantNullable();
@@ -6286,7 +6681,7 @@ export class SqliteScopeHost {
6286
6681
  this.ensureColumn(this.directory, '_substrat_sweep_runs', 'request_id', 'request_id TEXT');
6287
6682
  this.ensureColumn(this.directory, '_substrat_sweep_runs', 'event_type', 'event_type TEXT');
6288
6683
  this.ensureColumn(this.directory, '_substrat_sweep_runs', 'observed_at', 'observed_at TEXT');
6289
- this.directory.exec('CREATE UNIQUE INDEX IF NOT EXISTS _substrat_sweep_runs_intent ON _substrat_sweep_runs (request_id, unit)');
6684
+ this.ensureSweepRunsIntentKind();
6290
6685
  const existing = new Set(this.directory.prepare('PRAGMA table_info(scopes)').all().map((c) => c.name));
6291
6686
  for (const [column, ddl] of [
6292
6687
  ['parent_scope_id', 'parent_scope_id TEXT'],
@@ -6363,7 +6758,23 @@ export class SqliteScopeHost {
6363
6758
  * onto every event it emits. Absent for consumer dispatch: a consumer runs on
6364
6759
  * behalf of no operation, and the emitted row's NULL says so.
6365
6760
  */
6366
- 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.
6367
6778
  const principal = subject.id;
6368
6779
  const checker = this.checker;
6369
6780
  const relations = this.relations;
@@ -6388,7 +6799,6 @@ export class SqliteScopeHost {
6388
6799
  * `ctx.now()` deterministic only in the sense that each reading is separately
6389
6800
  * unpredictable.
6390
6801
  */
6391
- const at = this.clock();
6392
6802
  /**
6393
6803
  * The scope host's half of `ctx.atomic` (#770) — everything else is the
6394
6804
  * kernel's (`createAtomic`). SQLite gives us savepoints, which nest inside
@@ -6424,7 +6834,10 @@ export class SqliteScopeHost {
6424
6834
  // Lifted out of the object literal so `grant` can reuse it: a delegation
6425
6835
  // check has to be the SAME check the operation itself passes, or the two
6426
6836
  // could disagree about what the caller holds.
6427
- 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);
6428
6841
  if (overrideActor) {
6429
6842
  return {
6430
6843
  allowed: true,
@@ -6462,15 +6875,17 @@ export class SqliteScopeHost {
6462
6875
  scopeId: rt.scopeId,
6463
6876
  });
6464
6877
  };
6465
- return {
6878
+ const ctxRef = {
6466
6879
  tenantId: rt.tenantId,
6467
6880
  scopeId: rt.scopeId,
6468
6881
  principal,
6469
- sql: scopedSql(rt.db),
6882
+ sql: guardSecrets(scopedSql(rt.db), minted),
6470
6883
  now: () => at,
6471
6884
  emit: (event) => {
6472
6885
  assertImpersonationWrites(impersonation, 'ctx.emit');
6473
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);
6474
6889
  const full = domainEvent.parse({
6475
6890
  ...input,
6476
6891
  // #956: from the operation's instant, not the wall clock. `ORDER BY id`
@@ -6481,12 +6896,7 @@ export class SqliteScopeHost {
6481
6896
  occurredAt: at,
6482
6897
  tenantId: rt.tenantId,
6483
6898
  scopeId: rt.scopeId,
6484
- actor: overrideActor ??
6485
- (subject.kind === 'system'
6486
- ? { system: subject.id }
6487
- : subject.kind === 'connection'
6488
- ? { connection: subject.id }
6489
- : principal),
6899
+ actor: overrideActor ?? actorOf(subject),
6490
6900
  ...(passed.length ? { authorization: passed.map((p) => ({ ...p })) } : {}),
6491
6901
  ...(stamp ? { impersonation: stamp } : {}),
6492
6902
  // #1231: kernel-stamped like the two above — `domainEventInput.parse`
@@ -6516,6 +6926,8 @@ export class SqliteScopeHost {
6516
6926
  requestPlatform: (request) => {
6517
6927
  assertImpersonationWrites(impersonation, 'ctx.requestPlatform');
6518
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);
6519
6931
  // #1474: a platform-authored kind (`sweep-runs`) is the platform's to enqueue, never
6520
6932
  // module code's — its drain handler writes schedule verdicts the dashboard trusts.
6521
6933
  assertModuleEnqueueableKind(input.kind);
@@ -6528,12 +6940,7 @@ export class SqliteScopeHost {
6528
6940
  throw new Error(`too many pending platform requests (${pending}); retry once some have drained`);
6529
6941
  }
6530
6942
  const id = platformRequestId.parse(ulid());
6531
- const requestedBy = overrideActor ??
6532
- (subject.kind === 'system'
6533
- ? { system: subject.id }
6534
- : subject.kind === 'connection'
6535
- ? { connection: subject.id }
6536
- : principal);
6943
+ const requestedBy = overrideActor ?? actorOf(subject);
6537
6944
  rt.db
6538
6945
  .prepare(`INSERT INTO _substrat_platform_requests
6539
6946
  (id, kind, payload, requested_by, impersonation, status, attempts, requested_at)
@@ -6547,7 +6954,7 @@ export class SqliteScopeHost {
6547
6954
  // predicate is needed or possible: the runtime IS the scope.
6548
6955
  platformRequests: (filter) => {
6549
6956
  const q = platformRequestHistoryQuery(filter);
6550
- return rt.db.prepare(q.sql).all(...q.params).map(rowToPlatformRequest);
6957
+ return rt.db.prepare(q.sql).all(...q.params).map(platformRequestOf);
6551
6958
  },
6552
6959
  // #901. This scope's own spine, so no tenancy predicate is needed or
6553
6960
  // possible — the runtime IS the scope. The version is the last event's
@@ -6647,6 +7054,21 @@ export class SqliteScopeHost {
6647
7054
  .run(`principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`);
6648
7055
  },
6649
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
+ }),
6650
7072
  link: (child, parent) => {
6651
7073
  assertImpersonationWrites(impersonation, 'ctx.link');
6652
7074
  const allowed = relations.get(child.entityType);
@@ -6708,6 +7130,7 @@ export class SqliteScopeHost {
6708
7130
  return sealTo({ keyId: key.keyId, publicKey: key.publicKey }, plaintext);
6709
7131
  },
6710
7132
  };
7133
+ return ctxRef;
6711
7134
  }
6712
7135
  async applyPendingMigrations(rt) {
6713
7136
  const pending = [];
@@ -6816,10 +7239,98 @@ export class SqliteScopeHost {
6816
7239
  throw new Error(`scope ${scopeId} is reaped — its storage is gone and cannot be read`);
6817
7240
  }
6818
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
+ }
6819
7268
  scopeDbFor(tenantId, scopeId) {
6820
7269
  this.assertScopeReachable(tenantId, scopeId);
6821
7270
  return this.runtime(tenantId, scopeId).db;
6822
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
+ }
6823
7334
  /**
6824
7335
  * The additive spine-column migrations, shared by `runtime()` and the dump replay.
6825
7336
  * KERNEL_DDL is all IF NOT EXISTS, so a scope DB created before a column keeps the
@@ -6897,7 +7408,10 @@ export class SqliteScopeHost {
6897
7408
  const existing = this.scopes.get(key);
6898
7409
  if (existing)
6899
7410
  return existing;
6900
- 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.
6901
7415
  db.pragma('journal_mode = WAL');
6902
7416
  db.exec(KERNEL_DDL);
6903
7417
  this.ensureSpineColumns(db);
@@ -6914,7 +7428,9 @@ export class SqliteScopeHost {
6914
7428
  const created = {
6915
7429
  tenantId,
6916
7430
  scopeId,
7431
+ file,
6917
7432
  db,
7433
+ reader: null,
6918
7434
  actor: new ScopeActor(),
6919
7435
  appliedMigrations,
6920
7436
  mintEventId,
@@ -6945,6 +7461,20 @@ function cellToJson(v) {
6945
7461
  * `_substrat_*`" a mechanism rather than a lint rule (#954) — the kernel's own
6946
7462
  * spine writes use `rt.db` directly and never pass through here.
6947
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
+ }
6948
7478
  function scopedSql(db) {
6949
7479
  return guardSpine({
6950
7480
  query: (sql, params = []) => db.prepare(sql).all(...params),