@substrat-run/adapter-sqlite 0.116.0 → 0.118.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, redactSubjectJobRuns, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, 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') {
@@ -2807,10 +2970,10 @@ export class SqliteScopeHost {
2807
2970
  (id, module_id, job, instance, payload, status, cursor, counters, attempts,
2808
2971
  last_error, started_at, updated_at, next_attempt_at, ended_at)
2809
2972
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
2810
- const patchRun = db.prepare(`UPDATE _substrat_job_runs
2811
- SET status = ?, cursor = ?, counters = ?, attempts = ?, last_error = ?,
2812
- updated_at = ?, next_attempt_at = ?, ended_at = ?
2813
- WHERE id = ?`);
2973
+ // A compare-and-set on `running` (#1632): a pass that outlived an erasure must not
2974
+ // write its cursor back over the redaction. The kernel's statement, so both adapters
2975
+ // hold the same line.
2976
+ const patchRun = db.prepare(JOB_RUN_PATCH_SQL);
2814
2977
  const patchArgs = (id, p) => [p.status, p.cursor, p.counters, p.attempts, p.lastError, p.updatedAt, p.nextAttemptAt, p.endedAt, id];
2815
2978
  // `db.transaction` on better-sqlite3 runs its body SYNCHRONOUSLY inside a real
2816
2979
  // SQLite transaction, so nothing — not another turn of the event loop, not
@@ -2869,12 +3032,8 @@ export class SqliteScopeHost {
2869
3032
  .prepare('SELECT step, result, attempts, last_error FROM _substrat_job_steps WHERE run_id = ? AND step = ?')
2870
3033
  .get(runId, name) ?? null),
2871
3034
  recordStep: (runId, name, result, attempts, lastError, at) => turn(() => {
2872
- db.prepare(`INSERT INTO _substrat_job_steps (run_id, step, result, attempts, last_error, recorded_at)
2873
- VALUES (?, ?, ?, ?, ?, ?)
2874
- ON CONFLICT (run_id, step) DO UPDATE SET result = excluded.result,
2875
- attempts = excluded.attempts,
2876
- last_error = excluded.last_error,
2877
- recorded_at = excluded.recorded_at`).run(runId, name, result, attempts, lastError, at);
3035
+ // Only while the run is still `running` (#1632) — see `JOB_STEP_RECORD_SQL`.
3036
+ db.prepare(JOB_STEP_RECORD_SQL).run(runId, name, result, attempts, lastError, at, runId);
2878
3037
  }),
2879
3038
  };
2880
3039
  }
@@ -2936,7 +3095,7 @@ export class SqliteScopeHost {
2936
3095
  // "not granted" where the checker answers allow — a read-back that disagrees with
2937
3096
  // enforcement is worse than none, since it is the read an operator would trust.
2938
3097
  const rows = [
2939
- ...rt.db
3098
+ ...this.readDb(rt)
2940
3099
  .prepare(`SELECT subject, relation, expires_at FROM _substrat_tuples
2941
3100
  WHERE subject LIKE 'connection:%' AND relation LIKE 'granted:%'
2942
3101
  AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)`)
@@ -3016,7 +3175,7 @@ export class SqliteScopeHost {
3016
3175
  async executorDeadLetters(tenantId, scopeId) {
3017
3176
  const rt = this.runtime(tenantId, scopeId);
3018
3177
  await this.applyPendingMigrations(rt);
3019
- const rows = rt.db
3178
+ const rows = this.readDb(rt)
3020
3179
  .prepare(`SELECT d.event_id, d.consumer_module, d.attempts, d.error, d.delivered_at, o.type
3021
3180
  FROM _substrat_deliveries d
3022
3181
  JOIN _substrat_outbox o ON o.id = d.event_id
@@ -3037,27 +3196,36 @@ export class SqliteScopeHost {
3037
3196
  async listPlatformRequests(tenantId, scopeId) {
3038
3197
  const rt = this.runtime(tenantId, scopeId);
3039
3198
  await this.applyPendingMigrations(rt);
3040
- const rows = rt.db
3199
+ const rows = this.readDb(rt)
3041
3200
  .prepare(`SELECT ${PLATFORM_REQUEST_COLUMNS}
3042
3201
  FROM _substrat_platform_requests WHERE status = 'pending' ORDER BY id`)
3043
3202
  .all();
3044
- return rows.map(rowToPlatformRequest);
3203
+ // Tolerant (#1588): one undecodable row comes back naming why, never throws for the list.
3204
+ return rows.map(platformRequestOf);
3045
3205
  }
3046
3206
  async listPlatformRequestHistory(tenantId, scopeId, filter) {
3047
3207
  const rt = this.runtime(tenantId, scopeId);
3048
3208
  await this.applyPendingMigrations(rt);
3049
3209
  const q = platformRequestHistoryQuery(filter);
3050
- return rt.db.prepare(q.sql).all(...q.params).map(rowToPlatformRequest);
3210
+ return this.readDb(rt).prepare(q.sql).all(...q.params).map(platformRequestOf);
3051
3211
  }
3052
3212
  async settlePlatformRequest(tenantId, scopeId, id, outcome) {
3053
3213
  const rt = this.runtime(tenantId, scopeId);
3054
3214
  await this.applyPendingMigrations(rt);
3055
3215
  await rt.actor.enqueue(() => {
3216
+ // Compare-and-set on `pending` (#1600 review). The drain reads pending rows, runs a
3217
+ // handler, then settles — and between the read and the settle a subject erasure can
3218
+ // redact the row. Settling by `id` alone let that stale pass overwrite the redaction
3219
+ // and write a provider's reply, which can quote the person, back into `last_error`.
3220
+ // Nothing legitimate is refused by this: `listPlatformRequests` returns only pending
3221
+ // rows, so every settle in the tree targets one that was pending when it was read.
3222
+ // A settle that finds the row already terminal does nothing, deliberately silently —
3223
+ // throwing would make the drain's blanket catch retry a row that is correctly over.
3056
3224
  rt.db
3057
3225
  .prepare(`UPDATE _substrat_platform_requests
3058
3226
  SET status = ?, result = COALESCE(?, result), last_error = ?, last_failure = ?,
3059
3227
  attempts = attempts + 1, settled_at = ?
3060
- WHERE id = ?`)
3228
+ WHERE id = ? AND status = 'pending'`)
3061
3229
  .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
3230
  });
3063
3231
  }
@@ -3082,7 +3250,24 @@ export class SqliteScopeHost {
3082
3250
  ORDER BY o.id`)
3083
3251
  .all(consumer.eventType, mod.id);
3084
3252
  for (const row of rows) {
3085
- const event = this.parseOutboxRow(row);
3253
+ let event;
3254
+ try {
3255
+ event = domainEventOf(row);
3256
+ }
3257
+ catch (err) {
3258
+ // #1636: dead-letter an event that does not decode, exactly as a failed
3259
+ // handler is — the decode sat ABOVE the `try` below, so one bad row halted
3260
+ // every event of this type behind it, on every pass. The consumer is never
3261
+ // handed it: an event built from stand-ins is not one it may act on. Only
3262
+ // the decode is caught; the journal write is not, so a failure to record
3263
+ // this still surfaces as it would for a handler's dead letter.
3264
+ rt.db
3265
+ .prepare(`INSERT INTO _substrat_deliveries
3266
+ (event_id, consumer_module, delivered_at, error, invocation_id)
3267
+ VALUES (?, ?, ?, ?, ?)`)
3268
+ .run(row.id, mod.id, new Date().toISOString(), String(err), invocationId);
3269
+ continue;
3270
+ }
3086
3271
  const ctx = this.operationContext(rt, asPrincipal(this.systemPrincipal), {
3087
3272
  system: mod.id,
3088
3273
  });
@@ -3152,11 +3337,7 @@ export class SqliteScopeHost {
3152
3337
  // policy, carries no permission key, and is left to the module.
3153
3338
  if (!err.permission || !err.node)
3154
3339
  return;
3155
- const actor = subject.kind === 'system'
3156
- ? { system: subject.id }
3157
- : subject.kind === 'connection'
3158
- ? { connection: subject.id }
3159
- : subject.id;
3340
+ const actor = actorOf(subject);
3160
3341
  rt.db
3161
3342
  .prepare(`INSERT INTO _substrat_denials
3162
3343
  (id, actor, permission, tenant_id, scope_id, operation, impersonation,
@@ -3166,29 +3347,6 @@ export class SqliteScopeHost {
3166
3347
  // #1525: the call this refusal belongs to, as the caller named it.
3167
3348
  invocationId, new Date().toISOString());
3168
3349
  }
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
3350
  // -------------------------------------------------------------------------
3193
3351
  // Admin surface (enforcement input, §9.2.5)
3194
3352
  // -------------------------------------------------------------------------
@@ -3615,6 +3773,9 @@ export class SqliteScopeHost {
3615
3773
  (tenant_id, subject, relation, object, expires_at)
3616
3774
  VALUES (?, ?, ?, ?, ?)`)
3617
3775
  .run(tenantId, subject, relation, object, expiresAt ?? null);
3776
+ // The EXPLICIT grant: `INSERT OR REPLACE` clears a tombstone, because a re-grant must
3777
+ // grant. Provisioning seats with `seatScopeTuple` instead, which never un-revokes
3778
+ // (#1659) — keep the two apart.
3618
3779
  const writeScopeTuple = (node, subject, relation, object, expiresAt) => {
3619
3780
  if (!node.scopeId)
3620
3781
  throw new Error('scope tuple requires node.scopeId');
@@ -3635,6 +3796,82 @@ export class SqliteScopeHost {
3635
3796
  writeTenantTuple(node.tenantId, subject, `granted:${permission}`, `tenant:${node.tenantId}`, expiresAt);
3636
3797
  }
3637
3798
  };
3799
+ /**
3800
+ * A node's write, on the scope actor when the node is a scope (#1678). `invoke` holds a
3801
+ * raw `BEGIN IMMEDIATE` on the scope's connection across awaits, so a bare statement
3802
+ * issued mid-invoke joined that transaction and rolled back with it — after the verb
3803
+ * had answered success and audited. `body` holds the check AND the write, synchronously,
3804
+ * so nothing moves between them. Tenant-level: a directory write, which no scope's
3805
+ * transaction can hold, so it runs as it always did.
3806
+ */
3807
+ const onNode = (node, body) => node.scopeId
3808
+ ? this.runtime(node.tenantId, node.scopeId).actor.turn(body)
3809
+ : Promise.resolve().then(body);
3810
+ /** #1666: move one module's schedule switch on one scope — see `HostAdmin.revokeFromSystem`. */
3811
+ const switchSystem = async (actor, raw, to) => {
3812
+ const input = systemSwitch.parse(raw);
3813
+ const { tenantId, scopeId } = input.node;
3814
+ const scope = this.directory
3815
+ .prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
3816
+ .get(scopeId);
3817
+ if (!scope || scope.tenant_id !== tenantId) {
3818
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3819
+ }
3820
+ // AUDIT FIRST (#1666 review), exactly as the Cloudflare adapter: the intent row before
3821
+ // anything moves, the outcome row after, on every attempt. The directory and the
3822
+ // scope's file are separate databases, so a crash between them leaves an intent with
3823
+ // no recorded outcome — never a moved switch with no audit row.
3824
+ const operationId = ulid();
3825
+ const action = to === 'off' ? 'revokeFromSystem' : 'restoreToSystem';
3826
+ const target = { tenantId, scopeId };
3827
+ const base = { operationId, moduleId: input.moduleId, schedules: to };
3828
+ this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason: input.reason });
3829
+ let outcome;
3830
+ try {
3831
+ // A turn on the scope actor (#1666 review), exactly as the job store (#1577): `invoke`
3832
+ // holds a raw `BEGIN IMMEDIATE` on this same connection across awaits, so a bare
3833
+ // `db.transaction` issued mid-invoke became a SAVEPOINT inside it and rolled back
3834
+ // with it — after this verb had already audited `applied`. `turn` is re-entrant, so a
3835
+ // caller already inside one of this scope's tasks joins it rather than deadlocking.
3836
+ const rt = this.runtime(tenantId, scopeId);
3837
+ outcome = await rt.actor.turn(() => rt.db.transaction(() => switchSystemSchedules(switchSqlOf(rt.db), {
3838
+ moduleId: input.moduleId,
3839
+ scopeId,
3840
+ to,
3841
+ at: new Date().toISOString(),
3842
+ }))());
3843
+ }
3844
+ catch (err) {
3845
+ try {
3846
+ this.recordAdmin(actor, action, target, null, {
3847
+ ...base,
3848
+ phase: 'failed',
3849
+ error: err instanceof Error ? err.message : String(err),
3850
+ });
3851
+ }
3852
+ catch {
3853
+ // Best effort: the original error is what the caller must see.
3854
+ }
3855
+ throw err;
3856
+ }
3857
+ this.recordAdmin(actor, action, target, null, {
3858
+ ...base,
3859
+ phase: outcome.held ? 'applied' : 'refused',
3860
+ changed: outcome.changed,
3861
+ permissions: outcome.permissions,
3862
+ });
3863
+ if (!outcome.held) {
3864
+ throw substratError('not_found', `scope ${scopeId} holds no system grant for module '${input.moduleId}' — nothing to switch ${to} ` +
3865
+ `(check the module id: it is the module's manifest id, e.g. '@substrat-run/engine-absence')`);
3866
+ }
3867
+ return {
3868
+ operationId,
3869
+ moduleId: input.moduleId,
3870
+ schedules: to,
3871
+ changed: outcome.changed,
3872
+ permissions: outcome.permissions,
3873
+ };
3874
+ };
3638
3875
  return {
3639
3876
  // #603: fixed at construction — a host built without a box can never store a
3640
3877
  // credential, and saying so is what lets a transport answer 503 instead of 500.
@@ -3693,7 +3930,8 @@ export class SqliteScopeHost {
3693
3930
  assignRole: async (actor, assignment) => {
3694
3931
  const subject = `principal:${assignment.principalId}`;
3695
3932
  if (assignment.node.scopeId) {
3696
- writeScopeTuple(assignment.node, subject, `role:${assignment.roleKey}`, `scope:${assignment.node.scopeId}`);
3933
+ const scopeNode = assignment.node.scopeId;
3934
+ await onNode(assignment.node, () => writeScopeTuple(assignment.node, subject, `role:${assignment.roleKey}`, `scope:${scopeNode}`));
3697
3935
  }
3698
3936
  else {
3699
3937
  writeTenantTuple(assignment.node.tenantId, subject, `role:${assignment.roleKey}`, `tenant:${assignment.node.tenantId}`);
@@ -3709,11 +3947,14 @@ export class SqliteScopeHost {
3709
3947
  const now = new Date().toISOString();
3710
3948
  let changes;
3711
3949
  if (assignment.node.scopeId) {
3712
- const rt = this.runtime(assignment.node.tenantId, assignment.node.scopeId);
3713
- changes = rt.db
3950
+ // On the scope actor (#1678): a revoke that joined a stranger's transaction and
3951
+ // rolled back with it left the role LIVE after the caller was told it was gone.
3952
+ const scopeNode = assignment.node.scopeId;
3953
+ const rt = this.runtime(assignment.node.tenantId, scopeNode);
3954
+ changes = await rt.actor.turn(() => rt.db
3714
3955
  .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;
3956
+ WHERE subject = ? AND relation = ? AND object = ? AND revoked_at IS NULL`)
3957
+ .run(now, subject, relation, `scope:${scopeNode}`).changes);
3717
3958
  }
3718
3959
  else {
3719
3960
  changes = this.directory
@@ -3730,44 +3971,56 @@ export class SqliteScopeHost {
3730
3971
  // trust: the parse is where an `expiresAt` carrying a UTC offset is normalised
3731
3972
  // to Z text (#963), and liveness here is a lexicographic `expires_at > ?`.
3732
3973
  const grant = capabilityGrant.parse(raw);
3733
- writeGrant(`principal:${grant.principalId}`, grant.permission, grant.node, grant.entity, grant.expiresAt);
3974
+ await onNode(grant.node, () => writeGrant(`principal:${grant.principalId}`, grant.permission, grant.node, grant.entity, grant.expiresAt));
3734
3975
  this.recordAdmin(actor, 'grant', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, grant);
3735
3976
  },
3736
3977
  grantToConnection: async (actor, raw) => {
3737
3978
  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}`);
3979
+ const check = () => {
3980
+ const conn = this.connectionRow(grant.connectionId);
3981
+ if (conn.revoked_at) {
3982
+ throw new Error(`connection ${grant.connectionId} is revoked — grant nothing to it`);
3755
3983
  }
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'}'`);
3984
+ // The grant may not reach outside what the connection already is. A
3985
+ // connection is keyed (tenant, vertical, provider); letting it hold a
3986
+ // permission in another tenant would make the key decorative.
3987
+ if (conn.tenant_id !== grant.node.tenantId) {
3988
+ throw new Error(`connection ${grant.connectionId} belongs to tenant ${conn.tenant_id} and cannot ` +
3989
+ `be granted anything in ${grant.node.tenantId}`);
3759
3990
  }
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());
3991
+ if (grant.node.scopeId) {
3992
+ const scope = this.directory
3993
+ .prepare('SELECT tenant_id, vertical FROM scopes WHERE scope_id = ?')
3994
+ .get(grant.node.scopeId);
3995
+ if (!scope || scope.tenant_id !== grant.node.tenantId) {
3996
+ throw new Error(`unknown scope ${grant.node.scopeId} in tenant ${grant.node.tenantId}`);
3997
+ }
3998
+ if (scope.vertical !== conn.vertical) {
3999
+ throw new Error(`connection ${grant.connectionId} is for vertical '${conn.vertical}' and scope ` +
4000
+ `${grant.node.scopeId} runs '${scope.vertical ?? 'none'}'`);
4001
+ }
4002
+ }
4003
+ return conn;
4004
+ };
4005
+ // Once BEFORE the turn, so a refused grant never opens (and so creates) the scope's
4006
+ // file; then again inside it, with the tuple and the directory row, in ONE body on
4007
+ // the scope actor (#1678) — so the tuple cannot roll back with a stranger's
4008
+ // transaction and leave the directory row (#592) claiming a grant the scope lacks.
4009
+ check();
4010
+ const conn = await onNode(grant.node, () => {
4011
+ const row = check();
4012
+ writeGrant(subjectRef({ kind: 'connection', id: grant.connectionId }), grant.permission, grant.node, undefined, grant.expiresAt);
4013
+ // #592: the directory-side record, alongside the tuple. The tuple is checked
4014
+ // where it lives; this row is what provision/reconcile gather FROM, so the
4015
+ // grant reaches scopes provisioned after it without a human replaying it.
4016
+ this.directory
4017
+ .prepare(`INSERT OR REPLACE INTO _substrat_connection_grants
4018
+ (connection_id, tenant_id, vertical, permission, scope_id, expires_at,
4019
+ granted_by, granted_at, revoked_at)
4020
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`)
4021
+ .run(grant.connectionId, grant.node.tenantId, row.vertical, grant.permission, grant.node.scopeId ?? null, grant.expiresAt ?? null, grant.grantedBy, new Date().toISOString());
4022
+ return row;
4023
+ });
3771
4024
  this.recordAdmin(actor, 'grantToConnection', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId, vertical: conn.vertical }, null, {
3772
4025
  connectionId: grant.connectionId,
3773
4026
  provider: conn.provider,
@@ -3783,15 +4036,79 @@ export class SqliteScopeHost {
3783
4036
  // principal is bound to the module, and a scope only ever runs the modules
3784
4037
  // its own host registered.
3785
4038
  const grant = systemGrant.parse(raw);
3786
- writeGrant(subjectRef({ kind: 'system', id: grant.moduleId }), grant.permission, grant.node, undefined, grant.expiresAt);
4039
+ const write = () => writeGrant(subjectRef({ kind: 'system', id: grant.moduleId }), grant.permission, grant.node, undefined, grant.expiresAt);
4040
+ const scope = grant.node.scopeId;
4041
+ if (scope) {
4042
+ // #1666: refused while the module is switched off on this scope. The check and the
4043
+ // write share one synchronous body inside one turn on the scope actor, so no switch
4044
+ // can move between them — and (#1666 review) neither can land inside an unrelated
4045
+ // `invoke`'s open transaction and roll back with it after this verb returned. The
4046
+ // turn is re-entrant: from inside one of this scope's tasks it joins that task.
4047
+ // Restore is the lever; a grant is not.
4048
+ const rt = this.runtime(grant.node.tenantId, scope);
4049
+ await rt.actor.turn(() => {
4050
+ if (systemSwitchedOff(switchSqlOf(rt.db), grant.moduleId)) {
4051
+ throw substratError('conflict', systemSwitchedOffMessage(grant.moduleId, scope));
4052
+ }
4053
+ write();
4054
+ });
4055
+ }
4056
+ else {
4057
+ // Tenant-level: a directory write, which no scope's transaction can hold.
4058
+ write();
4059
+ }
3787
4060
  this.recordAdmin(actor, 'grantToSystem', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, { moduleId: grant.moduleId, permission: grant.permission, node: grant.node });
3788
4061
  },
4062
+ // #1666: the schedule kill switch and its lever back — `system-switch.ts` is the
4063
+ // whole rule, shared with the Cloudflare adapter; this is the directory check, the
4064
+ // transaction and the audit row around it.
4065
+ revokeFromSystem: async (actor, raw) => switchSystem(actor, raw, 'off'),
4066
+ restoreToSystem: async (actor, raw) => switchSystem(actor, raw, 'on'),
4067
+ // #1672 — the platform's two capability verbs, both a turn on the scope actor
4068
+ // (#1678: every scope-level admin write takes one, so none lands mid-invoke inside a
4069
+ // stranger's transaction) and both in the admin log without the secret or its hash.
4070
+ mintCapability: async (actor, tenantId, scopeId, input) => {
4071
+ const rt = await this.openActiveScope(tenantId, scopeId);
4072
+ const minted = await rt.actor.turn(() => mintBecomeCapability(spineSql(rt.db), input, actor, this.clock()));
4073
+ this.recordAdmin(actor, 'mintCapability', { tenantId, scopeId }, null, {
4074
+ capabilityId: minted.id,
4075
+ mode: 'become',
4076
+ principal: input.principal,
4077
+ expiresAt: input.expiresAt,
4078
+ maxUses: input.maxUses,
4079
+ label: input.label ?? null,
4080
+ });
4081
+ return minted;
4082
+ },
4083
+ revokeCapability: async (actor, tenantId, scopeId, capabilityId) => {
4084
+ const scope = this.directory
4085
+ .prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
4086
+ .get(scopeId);
4087
+ if (!scope || scope.tenant_id !== tenantId) {
4088
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4089
+ }
4090
+ const rt = this.runtime(tenantId, scopeId);
4091
+ await this.applyPendingMigrations(rt);
4092
+ const before = await rt.actor.turn(() => rt.db.transaction(() => revokeCapabilityAsPlatform(spineSql(rt.db), capabilityId, actor, this.clock()))());
4093
+ if (!before) {
4094
+ throw substratError('not_found', `no capability ${capabilityId} in scope ${scopeId}`);
4095
+ }
4096
+ this.recordAdmin(actor, 'revokeCapability', { tenantId, scopeId }, before, {
4097
+ capabilityId,
4098
+ revoked: true,
4099
+ });
4100
+ },
3789
4101
  grantToOrg: async (actor, orgId, permission, node, entity) => {
3790
4102
  // The org must exist in the node's tenant. A grant to a phantom org is
3791
4103
  // worse than an error: it looks applied, resolves for nobody, and shows up
3792
4104
  // in the permission diff as though access were conferred.
4105
+ // Checked once before the turn, so a refusal opens no scope file; then again with
4106
+ // the write in one body, on the scope actor when the node is a scope (#1678).
3793
4107
  requireOrg(node.tenantId, orgId);
3794
- writeGrant(`org:${orgId}`, permission, node, entity);
4108
+ await onNode(node, () => {
4109
+ requireOrg(node.tenantId, orgId);
4110
+ writeGrant(`org:${orgId}`, permission, node, entity);
4111
+ });
3795
4112
  this.recordAdmin(actor, 'grantToOrg', { tenantId: node.tenantId, scopeId: node.scopeId }, null, { orgId, permission, node, entity });
3796
4113
  },
3797
4114
  // -- vertical + version registry (#31) ---------------------------------
@@ -4452,24 +4769,18 @@ export class SqliteScopeHost {
4452
4769
  this.recordAdmin(actor, 'setScopeExpiresAt', { tenantId, scopeId }, { expiresAt: scope.expires_at }, { expiresAt });
4453
4770
  },
4454
4771
  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
- }));
4772
+ // The COMMITTED outbox (#1624). On the shared connection this read saw an in-flight
4773
+ // operation's uncommitted event, the drain shipped it, and the rollback left the lake
4774
+ // — which is append-only — holding an event the scope never had. The mark that
4775
+ // follows needs no such care: it stamps only rows that exist and are undrained.
4776
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4777
+ const page = db.prepare(`SELECT * FROM _substrat_outbox WHERE drained_at IS NULL ORDER BY id LIMIT ? OFFSET ?`);
4778
+ // The kernel's read, shared with the DO: the envelope and the columns lifted beside
4779
+ // it (#1242, #1237) are decoded once, and a row that will not decode is stepped over
4780
+ // rather than stalling the scope on it (#1636).
4781
+ const read = readUndrainedOutbox((offset, count) => page.all(count, offset), Math.min(Math.max(limit ?? 200, 1), 1000));
4782
+ this.recordAccess(actor, 'readUndrainedEvents', { tenantId, scopeId }, { limit }, read.events.length);
4783
+ return undrainedEventsOf(read);
4473
4784
  },
4474
4785
  markEventsDrained: async (actor, tenantId, scopeId, eventIds) => {
4475
4786
  // BEFORE the empty-batch shortcut, not after. "Nothing to mark" and "you may
@@ -4490,12 +4801,16 @@ export class SqliteScopeHost {
4490
4801
  // cannot absorb: the next read resumes from the middle of a batch already
4491
4802
  // in the lake, and the object it writes overlaps the one already there —
4492
4803
  // the duplication the first-id object key exists to prevent.
4493
- const drained = db.transaction(() => {
4804
+ //
4805
+ // On the scope actor (#1678): issued mid-invoke, this transaction was a SAVEPOINT
4806
+ // inside the invoke's, and its rollback un-marked a batch already in the lake — so
4807
+ // the next pass shipped it again, the same duplicate by another road.
4808
+ const drained = await this.runtime(tenantId, scopeId).actor.turn(() => db.transaction(() => {
4494
4809
  let n = 0;
4495
4810
  for (const id of eventIds)
4496
4811
  n += stmt.run(at, id).changes;
4497
4812
  return n;
4498
- })();
4813
+ })());
4499
4814
  // K-24's rule, one tier down (#1334): declaring domain payloads shipped is an
4500
4815
  // EGRESS, and the admin log is where "these events left the platform, at this
4501
4816
  // time, on this actor's say-so" is recorded. `drainAccessLog` audits the same
@@ -4532,7 +4847,8 @@ export class SqliteScopeHost {
4532
4847
  // an access row — the count itself as the result count, which is the fact an
4533
4848
  // auditor asking "who counted this tenant's outbox, and how much was there" wants.
4534
4849
  if (countOnly) {
4535
- const redrainable = db
4850
+ // The committed count (#1624): a stranger's uncommitted drain mark is not a fact yet.
4851
+ const redrainable = this.scopeReadDbFor(tenantId, scopeId)
4536
4852
  .prepare(`SELECT COUNT(*) AS c FROM _substrat_outbox
4537
4853
  WHERE drained_at IS NOT NULL AND drained_at < ?`)
4538
4854
  .get(drainedBefore).c;
@@ -4556,14 +4872,17 @@ export class SqliteScopeHost {
4556
4872
  // is never pruned, so the unbounded form scales with the scope's whole history.
4557
4873
  // SQLite is built here without UPDATE...LIMIT, so the batch is a subselect over the
4558
4874
  // primary key — oldest first, so a partial run leaves a prefix rather than holes.
4559
- const redrained = db
4875
+ //
4876
+ // On the scope actor (#1678), so a stranger's rollback cannot undo a reopen this
4877
+ // verb has already counted and receipted.
4878
+ const redrained = await this.runtime(tenantId, scopeId).actor.turn(() => db
4560
4879
  .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;
4880
+ WHERE id IN (
4881
+ SELECT id FROM _substrat_outbox
4882
+ WHERE drained_at IS NOT NULL AND drained_at < ?
4883
+ ORDER BY id LIMIT ?
4884
+ )`)
4885
+ .run(drainedBefore, REDRAIN_BATCH).changes);
4567
4886
  // The outcome beside the intent above — only when something changed, so a re-run
4568
4887
  // over an already reopened window leaves no row claiming it did something.
4569
4888
  if (redrained > 0) {
@@ -4574,7 +4893,7 @@ export class SqliteScopeHost {
4574
4893
  facetEvents: async (actor, tenantId, scopeId, input) => {
4575
4894
  // `facetEvents` is the sanctioned read: an erased payload yields the same
4576
4895
  // NULL a missing field does, and only the helper counts them apart.
4577
- const db = this.scopeDbFor(tenantId, scopeId);
4896
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4578
4897
  const result = facetEvents({ sql: scopedSql(db) }, input);
4579
4898
  this.recordAccess(actor, 'facetEvents', { tenantId, scopeId }, input, result.buckets.length);
4580
4899
  return result;
@@ -4584,7 +4903,7 @@ export class SqliteScopeHost {
4584
4903
  // only one that decodes the envelope's nullable facts (an erased payload, an
4585
4904
  // unrecorded authorization chain, nobody impersonating) as facts rather than
4586
4905
  // as missing data.
4587
- const db = this.scopeDbFor(tenantId, scopeId);
4906
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4588
4907
  const page = readHistory({ sql: scopedSql(db) }, { entityType: input.entityType, entityId: input.entityId }, { limit: input.limit, cursor: input.cursor });
4589
4908
  this.recordAccess(actor, 'entityHistory', { tenantId, scopeId }, { entityType: input.entityType, entityId: input.entityId }, page.entries.length);
4590
4909
  return page;
@@ -4593,7 +4912,7 @@ export class SqliteScopeHost {
4593
4912
  // #1237: the sanctioned walk. The helper owns the one distinction the whole
4594
4913
  // view rests on — a null cause with an operation is the beginning of the
4595
4914
  // chain, a null cause without one is the trail running out.
4596
- const db = this.scopeDbFor(tenantId, scopeId);
4915
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4597
4916
  const result = walkEventCause({ sql: scopedSql(db) }, input.eventId, input.maxDepth);
4598
4917
  this.recordAccess(actor, 'eventCause', { tenantId, scopeId }, { eventId: input.eventId }, result.chain.length);
4599
4918
  return result;
@@ -4601,27 +4920,27 @@ export class SqliteScopeHost {
4601
4920
  eventEffects: async (actor, tenantId, scopeId, input) => {
4602
4921
  // #1237 forward: the helper owns the two delivery traps — `delivered_at`
4603
4922
  // meaning two different things, and an empty list being ambiguous.
4604
- const db = this.scopeDbFor(tenantId, scopeId);
4923
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4605
4924
  const tree = walkEventEffects({ sql: scopedSql(db) }, input.eventId, input.maxNodes);
4606
4925
  this.recordAccess(actor, 'eventEffects', { tenantId, scopeId }, { eventId: input.eventId }, tree.count);
4607
4926
  return tree;
4608
4927
  },
4609
4928
  invocationEvents: async (actor, tenantId, scopeId, input) => {
4610
4929
  // #1237: one call's events, siblings included — what neither walk reaches.
4611
- const db = this.scopeDbFor(tenantId, scopeId);
4930
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4612
4931
  const read = readInvocation({ sql: scopedSql(db) }, input.invocationId, input.limit);
4613
4932
  this.recordAccess(actor, 'invocationEvents', { tenantId, scopeId }, { invocationId: input.invocationId }, read.events.length);
4614
4933
  return read;
4615
4934
  },
4616
4935
  deadLetters: async (actor, tenantId, scopeId, input) => {
4617
4936
  // #1525: every delivery that gave up, scope-wide — what the walks reach only by event.
4618
- const db = this.scopeDbFor(tenantId, scopeId);
4937
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4619
4938
  const page = readDeadLetters({ sql: scopedSql(db) }, { limit: input.limit, cursor: input.cursor });
4620
4939
  this.recordAccess(actor, 'deadLetters', { tenantId, scopeId }, null, page.entries.length);
4621
4940
  return page;
4622
4941
  },
4623
4942
  scopeAppliedMigrations: async (actor, tenantId, scopeId) => {
4624
- const db = this.scopeDbFor(tenantId, scopeId);
4943
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4625
4944
  const rows = db
4626
4945
  .prepare(`SELECT module_id, version, applied_at FROM _substrat_migrations
4627
4946
  ORDER BY applied_at DESC, module_id, version LIMIT 100`)
@@ -4645,6 +4964,16 @@ export class SqliteScopeHost {
4645
4964
  this.recordAccess(actor, 'scopeMigrationBookmarks', { tenantId, scopeId }, null, 0);
4646
4965
  return [];
4647
4966
  },
4967
+ scopeDatabaseSize: async (actor, tenantId, scopeId) => {
4968
+ // #1524: the SQLite twin of a DO's `databaseSize`. It counts allocated pages,
4969
+ // free ones included, which is what the file costs on disk, not the live row bytes.
4970
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4971
+ const pages = db.pragma('page_count', { simple: true });
4972
+ const pageSize = db.pragma('page_size', { simple: true });
4973
+ const bytes = pages * pageSize;
4974
+ this.recordAccess(actor, 'scopeDatabaseSize', { tenantId, scopeId }, null, 1);
4975
+ return bytes;
4976
+ },
4648
4977
  rewindScope: async () => {
4649
4978
  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
4979
  },
@@ -4877,7 +5206,7 @@ export class SqliteScopeHost {
4877
5206
  listScopeTables: async (actor, tenantId, scopeId) => {
4878
5207
  // K-3: the (tenantId, scopeId) pair is cross-checked before we open anything;
4879
5208
  // a scope under a different tenant is unreachable, never another tenant's DB.
4880
- const db = this.scopeDbFor(tenantId, scopeId);
5209
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4881
5210
  const names = db
4882
5211
  .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`)
4883
5212
  .all().map((r) => r.name);
@@ -4890,7 +5219,7 @@ export class SqliteScopeHost {
4890
5219
  return tables;
4891
5220
  },
4892
5221
  readScopeTable: async (actor, tenantId, scopeId, input) => {
4893
- const db = this.scopeDbFor(tenantId, scopeId);
5222
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4894
5223
  // Validate the table against the LIVE schema — an unknown name throws, it is
4895
5224
  // never interpolated blind. That validated name is the only thing that reaches
4896
5225
  // the query, so the quoted identifier below carries no user input.
@@ -4907,7 +5236,7 @@ export class SqliteScopeHost {
4907
5236
  return { table: input.table, columns, rows, rowCount, limit, offset };
4908
5237
  },
4909
5238
  queryScope: async (actor, tenantId, scopeId, input) => {
4910
- const db = this.scopeDbFor(tenantId, scopeId);
5239
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4911
5240
  // Layer 1, shared: the kernel's textual gate (single statement, read verbs
4912
5241
  // only) — the checked statement is the one that runs.
4913
5242
  const sql = assertReadOnlyQuery(input.sql);
@@ -4942,14 +5271,14 @@ export class SqliteScopeHost {
4942
5271
  // read records to the K-24 access log. What differs is that these two answer a
4943
5272
  // question about the permission model rather than about the data.
4944
5273
  listDenials: async (actor, tenantId, scopeId, filter) => {
4945
- const db = this.scopeDbFor(tenantId, scopeId);
5274
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4946
5275
  const q = denialListQuery(filter);
4947
5276
  const rows = db.prepare(q.sql).all(...q.params).map(mapDenialRow);
4948
5277
  this.recordAccess(actor, 'listDenials', { tenantId, scopeId }, filter ?? null, rows.length);
4949
5278
  return rows;
4950
5279
  },
4951
5280
  summarizeDenials: async (actor, tenantId, scopeId, filter) => {
4952
- const db = this.scopeDbFor(tenantId, scopeId);
5281
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4953
5282
  const b = denialSummaryQuery(filter);
4954
5283
  // Grouped as the filter asked (#1456), and the answer says which way.
4955
5284
  const grouped = mapDenialSummaryBuckets(b.groupBy, db.prepare(b.sql).all(...b.params));
@@ -4969,7 +5298,7 @@ export class SqliteScopeHost {
4969
5298
  },
4970
5299
  exportScope: async (actor, tenantId, scopeId) => {
4971
5300
  // K-3: cross-check the pair before opening anything (same as the introspection reads).
4972
- const db = this.scopeDbFor(tenantId, scopeId);
5301
+ const db = this.scopeReadDbFor(tenantId, scopeId);
4973
5302
  // Every real table, keeping the `_substrat_*` spine but dropping SQLite's own
4974
5303
  // `sqlite_*` internals — those are auto-managed and `CREATE TABLE sqlite_*` is
4975
5304
  // rejected on reload. `sql` is the CREATE statement, replayed to rebuild the schema.
@@ -5110,13 +5439,11 @@ export class SqliteScopeHost {
5110
5439
  throw new Error(`scope ${scopeId} still resolves hostname '${bound.hostname}' — ` +
5111
5440
  `unbind it before reaping (reap wipes storage and cannot be undone)`);
5112
5441
  }
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
- }
5442
+ // The reader goes with the writer (#1624): an open handle on a removed file keeps
5443
+ // reading the unlinked bytes, which is the reaped data answering as if it were live.
5444
+ const rt = this.scopes.get(`${tenantId}/${scopeId}`);
5445
+ if (rt)
5446
+ this.closeRuntime(rt);
5120
5447
  rmSync(join(this.dir, `${tenantId}__${scopeId}.sqlite`), { force: true });
5121
5448
  // The recoverable copy the caller stored first (#493), named in the audit entry so
5122
5449
  // the trail answers "was there a backup" without correlating two timestamps.
@@ -5155,15 +5482,46 @@ export class SqliteScopeHost {
5155
5482
  // and transaction facts remain". A consumer's timeline still shows that something
5156
5483
  // happened, to what, and when; it no longer shows who or what was said.
5157
5484
  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);
5485
+ // One instant for the whole erasure, read before the first write: the intent
5486
+ // tombstones below and the key's own tombstone should not disagree about when a
5487
+ // person was erased.
5162
5488
  const at = new Date().toISOString();
5489
+ // Both scope-side redactions in ONE turn on the scope actor (#1678): issued while an
5490
+ // invoke held its transaction open, they joined it, and its rollback put the
5491
+ // person's PII back after this verb had destroyed the key and receipted the erasure.
5492
+ const { redacted, intentsRedacted, jobRunsRedacted } = await this.runtime(tenantId, scopeId).actor.turn(() => ({
5493
+ redacted: db
5494
+ .prepare(`UPDATE _substrat_outbox SET payload = NULL
5495
+ WHERE subject_id = ? AND pii_class != 'none' AND payload IS NOT NULL`)
5496
+ .run(subjectId),
5497
+ // The spine's OTHER copy of an event (#1600). A CP-less host routes a connector
5498
+ // delivery it cannot run into `_substrat_platform_requests` with the whole event
5499
+ // embedded, and nothing ever deletes those rows — so the same line has to be held
5500
+ // here, or the redaction above only moves the name one table across. Same
5501
+ // idempotence as the outbox half: a re-run finds the tombstones and changes
5502
+ // nothing.
5503
+ intentsRedacted: this.redactSubjectIntents(db, subjectId, at),
5504
+ // A failed delivery's error text about one of those events (#1632) — keyed by
5505
+ // event id, so the outbox predicate names it. Not a copy; not counted.
5506
+ deliveries: db
5507
+ .prepare(DELIVERY_ERROR_REDACTION_SQL)
5508
+ .run(REDACTED_DELIVERY_NOTE, REDACTED_DELIVERY_NOTE, subjectId),
5509
+ // The job-run tables (#1632): a run, its cursor or a step's memo can hold a copy
5510
+ // of the same event. The kernel's walk, so the DO runs the identical SQL.
5511
+ jobRunsRedacted: redactSubjectJobRuns((sql, params) => {
5512
+ const stmt = db.prepare(sql);
5513
+ if (stmt.reader)
5514
+ return stmt.all(...params);
5515
+ stmt.run(...params);
5516
+ return [];
5517
+ }, subjectId, at),
5518
+ }));
5163
5519
  const { existed } = await this.subjectKeysFor(tenantId, scopeId).destroy(subjectId, at);
5164
5520
  const receipt = subjectShredReceipt.parse({
5165
5521
  subjectId,
5166
5522
  eventsRedacted: redacted.changes,
5523
+ intentsRedacted,
5524
+ jobRunsRedacted,
5167
5525
  keyDestroyed: existed,
5168
5526
  tombstoned: true,
5169
5527
  });
@@ -5171,7 +5529,9 @@ export class SqliteScopeHost {
5171
5529
  // mutation, the access log because it destroys evidence. An erasure is the one
5172
5530
  // action where "who asked for this to disappear" is itself the record.
5173
5531
  this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
5174
- this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, redacted.changes);
5532
+ // BOTH counts: the access log's number is "how much evidence this destroyed", and
5533
+ // an intent payload is a whole event's worth of it.
5534
+ this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, redacted.changes + intentsRedacted + jobRunsRedacted);
5175
5535
  return receipt;
5176
5536
  },
5177
5537
  // -- impersonation (K-42, #868) ----------------------------------------
@@ -6148,6 +6508,24 @@ export class SqliteScopeHost {
6148
6508
  // where the whole replay is already inside one.
6149
6509
  db.transaction(() => db.exec(SCHEDULE_STATE_REBUILD))();
6150
6510
  }
6511
+ /**
6512
+ * #1573: run a create-copy-drop-rename against the directory as ONE transaction.
6513
+ * `exec` of four statements is four autocommits, and the state between DROP and
6514
+ * RENAME is the one to fear: the next open runs the bootstrap first, whose `CREATE
6515
+ * TABLE IF NOT EXISTS` puts an EMPTY table of the new shape back, detection then
6516
+ * reads that shape and concludes the migration is done — and every copied row stays
6517
+ * orphaned in the `_new` table, with nothing ever erroring. Atomic, neither
6518
+ * intermediate state is reachable, so there is no recovery path to write (or to
6519
+ * leave untested).
6520
+ *
6521
+ * `db.transaction` nests as a SAVEPOINT, as `ensureScheduleStateKind` (#1571) relies
6522
+ * on. Each script leads with `DROP TABLE IF EXISTS <table>_new` to absorb a scratch
6523
+ * table arriving from BELOW the transaction (a torn copy of the file, a backup that
6524
+ * captured one mid-rebuild) — belt, not the fix.
6525
+ */
6526
+ rebuildAtomically(script) {
6527
+ this.directory.transaction(() => this.directory.exec(script))();
6528
+ }
6151
6529
  /**
6152
6530
  * Rebuild `_substrat_identities` when it still carries the pre-K-22 global key.
6153
6531
  * A PRIMARY KEY cannot be ALTERed, so this is create-copy-drop-rename.
@@ -6168,7 +6546,9 @@ export class SqliteScopeHost {
6168
6546
  .get('_substrat_identities');
6169
6547
  if (!row || row.sql.includes('PRIMARY KEY (tenant_id, provider, external_id)'))
6170
6548
  return;
6171
- this.directory.exec(`
6549
+ // #1573: one transaction — see `rebuildAtomically`.
6550
+ this.rebuildAtomically(`
6551
+ DROP TABLE IF EXISTS _substrat_identities_new;
6172
6552
  CREATE TABLE _substrat_identities_new (
6173
6553
  provider TEXT NOT NULL,
6174
6554
  external_id TEXT NOT NULL,
@@ -6199,7 +6579,9 @@ export class SqliteScopeHost {
6199
6579
  .get('_substrat_admin_log');
6200
6580
  if (!row || !/tenant_id TEXT NOT NULL/.test(row.sql))
6201
6581
  return;
6202
- this.directory.exec(`
6582
+ // #1573: one transaction — see `rebuildAtomically`.
6583
+ this.rebuildAtomically(`
6584
+ DROP TABLE IF EXISTS _substrat_admin_log_new;
6203
6585
  CREATE TABLE _substrat_admin_log_new (
6204
6586
  id TEXT PRIMARY KEY,
6205
6587
  actor TEXT NOT NULL,
@@ -6218,6 +6600,30 @@ export class SqliteScopeHost {
6218
6600
  ALTER TABLE _substrat_admin_log_new RENAME TO _substrat_admin_log;
6219
6601
  `);
6220
6602
  }
6603
+ /**
6604
+ * #1572: `_substrat_sweep_runs_intent` with `kind` in it, on a directory whose index
6605
+ * predates that. `CREATE UNIQUE INDEX IF NOT EXISTS` matches on the name alone, so the
6606
+ * bootstrap cannot do this: on an existing directory it sees the old index and skips,
6607
+ * and the schedule/freshness collision the new key refuses stays live for good.
6608
+ *
6609
+ * Detected from `sqlite_master.sql`, like the table rebuilds above, and replaced as
6610
+ * DROP-then-CREATE in one transaction (`rebuildAtomically`). Less rides on that than on
6611
+ * a table rebuild — the index holds no rows, and one dropped but not re-created comes
6612
+ * back on the next open, because the bootstrap creates it by name — but there is then
6613
+ * no committed directory with no dedupe key at all. The CREATE cannot meet a row it
6614
+ * refuses: every set of rows unique on (request_id, unit) is unique on the wider key.
6615
+ */
6616
+ ensureSweepRunsIntentKind() {
6617
+ const row = this.directory
6618
+ .prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?")
6619
+ .get('_substrat_sweep_runs_intent');
6620
+ if (row && sweepRunsIntentHasKind(row.sql))
6621
+ return;
6622
+ this.rebuildAtomically(`
6623
+ DROP INDEX IF EXISTS _substrat_sweep_runs_intent;
6624
+ ${SWEEP_RUNS_INTENT_INDEX}
6625
+ `);
6626
+ }
6221
6627
  ensureDirectoryColumns() {
6222
6628
  this.ensureIdentityKey();
6223
6629
  this.ensureAdminLogTenantNullable();
@@ -6286,7 +6692,7 @@ export class SqliteScopeHost {
6286
6692
  this.ensureColumn(this.directory, '_substrat_sweep_runs', 'request_id', 'request_id TEXT');
6287
6693
  this.ensureColumn(this.directory, '_substrat_sweep_runs', 'event_type', 'event_type TEXT');
6288
6694
  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)');
6695
+ this.ensureSweepRunsIntentKind();
6290
6696
  const existing = new Set(this.directory.prepare('PRAGMA table_info(scopes)').all().map((c) => c.name));
6291
6697
  for (const [column, ddl] of [
6292
6698
  ['parent_scope_id', 'parent_scope_id TEXT'],
@@ -6363,7 +6769,23 @@ export class SqliteScopeHost {
6363
6769
  * onto every event it emits. Absent for consumer dispatch: a consumer runs on
6364
6770
  * behalf of no operation, and the emitted row's NULL says so.
6365
6771
  */
6366
- operation) {
6772
+ operation,
6773
+ /**
6774
+ * #1672: the secrets `ctx.capabilities.mint` hands out during this invocation. The
6775
+ * stub owns the array (it withholds them from the idempotency recording); this
6776
+ * context appends to it and holds `ctx.sql`, `ctx.emit` and `ctx.requestPlatform` to
6777
+ * it — the tripwire that catches a module persisting one by accident (not a boundary
6778
+ * against one that means to; see `assertNoSecret`).
6779
+ */
6780
+ minted = [],
6781
+ /**
6782
+ * #1672: the instant to stamp, when the caller already read one. The exchange passes the
6783
+ * instant its row write used, so the `capability.exercised` event's `occurredAt` and the
6784
+ * row's `last_used_at` are the same value — a second clock read could disagree.
6785
+ */
6786
+ at = this.clock()) {
6787
+ // For a connection, system or capability subject this carries THAT id so the type
6788
+ // holds — it is not a person, and the event actor below says what it is instead.
6367
6789
  const principal = subject.id;
6368
6790
  const checker = this.checker;
6369
6791
  const relations = this.relations;
@@ -6388,7 +6810,6 @@ export class SqliteScopeHost {
6388
6810
  * `ctx.now()` deterministic only in the sense that each reading is separately
6389
6811
  * unpredictable.
6390
6812
  */
6391
- const at = this.clock();
6392
6813
  /**
6393
6814
  * The scope host's half of `ctx.atomic` (#770) — everything else is the
6394
6815
  * kernel's (`createAtomic`). SQLite gives us savepoints, which nest inside
@@ -6424,7 +6845,10 @@ export class SqliteScopeHost {
6424
6845
  // Lifted out of the object literal so `grant` can reuse it: a delegation
6425
6846
  // check has to be the SAME check the operation itself passes, or the two
6426
6847
  // could disagree about what the caller holds.
6427
- const runCheck = async (permission, entity) => {
6848
+ const runCheck = async (unparsed, entity) => {
6849
+ // #1642: parsed before the override actor's early return, which never reaches
6850
+ // the checker — a cast key would otherwise become that path's proof relation.
6851
+ const permission = assertPermissionKey(unparsed);
6428
6852
  if (overrideActor) {
6429
6853
  return {
6430
6854
  allowed: true,
@@ -6462,15 +6886,17 @@ export class SqliteScopeHost {
6462
6886
  scopeId: rt.scopeId,
6463
6887
  });
6464
6888
  };
6465
- return {
6889
+ const ctxRef = {
6466
6890
  tenantId: rt.tenantId,
6467
6891
  scopeId: rt.scopeId,
6468
6892
  principal,
6469
- sql: scopedSql(rt.db),
6893
+ sql: guardSecrets(scopedSql(rt.db), minted),
6470
6894
  now: () => at,
6471
6895
  emit: (event) => {
6472
6896
  assertImpersonationWrites(impersonation, 'ctx.emit');
6473
6897
  const input = domainEventInput.parse(event);
6898
+ // #1672: the COMPLETE parsed event — entity id, type and subject as well as payload.
6899
+ assertNoSecret('ctx.emit', input, minted);
6474
6900
  const full = domainEvent.parse({
6475
6901
  ...input,
6476
6902
  // #956: from the operation's instant, not the wall clock. `ORDER BY id`
@@ -6481,12 +6907,7 @@ export class SqliteScopeHost {
6481
6907
  occurredAt: at,
6482
6908
  tenantId: rt.tenantId,
6483
6909
  scopeId: rt.scopeId,
6484
- actor: overrideActor ??
6485
- (subject.kind === 'system'
6486
- ? { system: subject.id }
6487
- : subject.kind === 'connection'
6488
- ? { connection: subject.id }
6489
- : principal),
6910
+ actor: overrideActor ?? actorOf(subject),
6490
6911
  ...(passed.length ? { authorization: passed.map((p) => ({ ...p })) } : {}),
6491
6912
  ...(stamp ? { impersonation: stamp } : {}),
6492
6913
  // #1231: kernel-stamped like the two above — `domainEventInput.parse`
@@ -6516,6 +6937,8 @@ export class SqliteScopeHost {
6516
6937
  requestPlatform: (request) => {
6517
6938
  assertImpersonationWrites(impersonation, 'ctx.requestPlatform');
6518
6939
  const input = platformRequestInput.parse(request);
6940
+ // #1672: the COMPLETE parsed request — its `kind` is persisted as surely as its payload.
6941
+ assertNoSecret('ctx.requestPlatform', input, minted);
6519
6942
  // #1474: a platform-authored kind (`sweep-runs`) is the platform's to enqueue, never
6520
6943
  // module code's — its drain handler writes schedule verdicts the dashboard trusts.
6521
6944
  assertModuleEnqueueableKind(input.kind);
@@ -6528,12 +6951,7 @@ export class SqliteScopeHost {
6528
6951
  throw new Error(`too many pending platform requests (${pending}); retry once some have drained`);
6529
6952
  }
6530
6953
  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);
6954
+ const requestedBy = overrideActor ?? actorOf(subject);
6537
6955
  rt.db
6538
6956
  .prepare(`INSERT INTO _substrat_platform_requests
6539
6957
  (id, kind, payload, requested_by, impersonation, status, attempts, requested_at)
@@ -6547,7 +6965,7 @@ export class SqliteScopeHost {
6547
6965
  // predicate is needed or possible: the runtime IS the scope.
6548
6966
  platformRequests: (filter) => {
6549
6967
  const q = platformRequestHistoryQuery(filter);
6550
- return rt.db.prepare(q.sql).all(...q.params).map(rowToPlatformRequest);
6968
+ return rt.db.prepare(q.sql).all(...q.params).map(platformRequestOf);
6551
6969
  },
6552
6970
  // #901. This scope's own spine, so no tenancy predicate is needed or
6553
6971
  // possible — the runtime IS the scope. The version is the last event's
@@ -6647,6 +7065,21 @@ export class SqliteScopeHost {
6647
7065
  .run(`principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`);
6648
7066
  },
6649
7067
  atomic: createAtomic(runSub, { passed, signals }),
7068
+ // #1672: mint / revoke / list, written once in the kernel. The raw spine seam (this
7069
+ // is the kernel's own write to `_substrat_capabilities`), the operation's OWN check —
7070
+ // the predicate `grant` shares — and `ctx.emit` for the minted/revoked events. A
7071
+ // consumer's override actor is passed as the system actor it is, so a consumer cannot
7072
+ // mint: its checks allow unconditionally, which would make "the minter holds it" vacuous.
7073
+ capabilities: createCapabilityVerbs({
7074
+ sql: spineSql(rt.db),
7075
+ subject: overrideActor ? { kind: 'system', id: overrideActor.system } : subject,
7076
+ now: at,
7077
+ check: runCheck,
7078
+ emit: (event) => ctxRef.emit(event),
7079
+ isOperation: (name) => this.operations.has(name),
7080
+ assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
7081
+ minted,
7082
+ }),
6650
7083
  link: (child, parent) => {
6651
7084
  assertImpersonationWrites(impersonation, 'ctx.link');
6652
7085
  const allowed = relations.get(child.entityType);
@@ -6708,6 +7141,7 @@ export class SqliteScopeHost {
6708
7141
  return sealTo({ keyId: key.keyId, publicKey: key.publicKey }, plaintext);
6709
7142
  },
6710
7143
  };
7144
+ return ctxRef;
6711
7145
  }
6712
7146
  async applyPendingMigrations(rt) {
6713
7147
  const pending = [];
@@ -6816,10 +7250,98 @@ export class SqliteScopeHost {
6816
7250
  throw new Error(`scope ${scopeId} is reaped — its storage is gone and cannot be read`);
6817
7251
  }
6818
7252
  }
7253
+ /**
7254
+ * The intent-journal half of `shredSubject` (#1600) — the DO's `redactSubject` twin.
7255
+ *
7256
+ * Row-by-row rather than one `UPDATE … WHERE`: the decision is structural (does this
7257
+ * payload embed an event the erasure redacts?) and SQL cannot make it, because an
7258
+ * intent payload is opaque JSON with no `pii_class` column to test. The SQL narrows to
7259
+ * the rows that could possibly match; the kernel predicate decides; the kernel's
7260
+ * statement writes. All three are shared with the Durable-Object adapter so the two
7261
+ * cannot drift about what a redacted intent is.
7262
+ *
7263
+ * Returns how many rows it redacted — zero on a re-run, the tombstones having already
7264
+ * stopped matching.
7265
+ */
7266
+ redactSubjectIntents(db, subjectId, at) {
7267
+ const q = platformRequestRedactionQuery(subjectId);
7268
+ const candidates = db.prepare(q.sql).all(...q.params);
7269
+ const write = db.prepare(PLATFORM_REQUEST_REDACTION_SQL);
7270
+ let redacted = 0;
7271
+ for (const row of candidates) {
7272
+ if (!intentPayloadCarriesSubject(row.payload, subjectId))
7273
+ continue;
7274
+ write.run(...platformRequestRedactionParams(row.id, subjectId, at));
7275
+ redacted += 1;
7276
+ }
7277
+ return redacted;
7278
+ }
6819
7279
  scopeDbFor(tenantId, scopeId) {
6820
7280
  this.assertScopeReachable(tenantId, scopeId);
6821
7281
  return this.runtime(tenantId, scopeId).db;
6822
7282
  }
7283
+ /**
7284
+ * The connection a `HostAdmin` READ runs on (#1624) — `scopeDbFor`'s twin, with the
7285
+ * same K-3 and reap gate in front of it.
7286
+ *
7287
+ * From OUTSIDE the scope's actor it is the scope's read-only connection. `invoke` holds
7288
+ * a `BEGIN IMMEDIATE` open on the shared connection across its awaits, so a read there
7289
+ * saw a stranger's uncommitted rows: a lake drain shipped an event that then rolled
7290
+ * back — permanently, since the lake is append-only — and an export captured one into a
7291
+ * backup. The file is WAL, so the reader sees exactly what was committed.
7292
+ *
7293
+ * From INSIDE one of the scope's actor tasks (an executor handed `HostAdmin`, reading
7294
+ * its own scope) it is the shared connection, as before. The only transaction open
7295
+ * there is the caller's own unit — the actor serializes everything else — so reading
7296
+ * it is reading your own writes, which is the answer the caller is owed and the one it
7297
+ * had. `ScopeActor.turn` makes the same call for the writers (#1678).
7298
+ */
7299
+ scopeReadDbFor(tenantId, scopeId) {
7300
+ this.assertScopeReachable(tenantId, scopeId);
7301
+ return this.readDb(this.runtime(tenantId, scopeId));
7302
+ }
7303
+ /**
7304
+ * `scopeReadDbFor` without the directory gate, for the host-side reads that already
7305
+ * resolved their runtime — the platform-intent drain's `listPlatformRequests` among them,
7306
+ * where a stranger's uncommitted intent was a platform effect about to be carried out for
7307
+ * an operation that then rolled back.
7308
+ */
7309
+ readDb(rt) {
7310
+ if (rt.actor.holds())
7311
+ return rt.db;
7312
+ const key = `${rt.tenantId}/${rt.scopeId}`;
7313
+ if (rt.reader) {
7314
+ // Most recently used goes to the back of the insertion order, which is the cap's LRU.
7315
+ this.readers.delete(key);
7316
+ this.readers.set(key, rt);
7317
+ return rt.reader;
7318
+ }
7319
+ // `runtime()` opened the writer first, in WAL, so the file and its `-shm` exist.
7320
+ rt.reader = new Database(rt.file, { readonly: true, fileMustExist: true });
7321
+ this.readers.set(key, rt);
7322
+ // The cap: a lake drain sweeps every scope, and without it every open scope would end
7323
+ // up holding two connections. The oldest reader is closed; the next read reopens it.
7324
+ while (this.readers.size > READER_CAP) {
7325
+ const [oldest, victim] = this.readers.entries().next().value;
7326
+ this.readers.delete(oldest);
7327
+ victim.reader?.close();
7328
+ victim.reader = null;
7329
+ }
7330
+ return rt.reader;
7331
+ }
7332
+ /**
7333
+ * Close a scope's connections and forget its runtime — the reader with the writer, so
7334
+ * neither outlives the file a reap or a snapshot delete is about to remove.
7335
+ */
7336
+ closeRuntime(rt) {
7337
+ const key = `${rt.tenantId}/${rt.scopeId}`;
7338
+ rt.reader?.close();
7339
+ rt.reader = null;
7340
+ this.readers.delete(key);
7341
+ rt.db.close();
7342
+ this.scopes.delete(key);
7343
+ this.scopesById.delete(rt.scopeId);
7344
+ }
6823
7345
  /**
6824
7346
  * The additive spine-column migrations, shared by `runtime()` and the dump replay.
6825
7347
  * KERNEL_DDL is all IF NOT EXISTS, so a scope DB created before a column keeps the
@@ -6897,7 +7419,10 @@ export class SqliteScopeHost {
6897
7419
  const existing = this.scopes.get(key);
6898
7420
  if (existing)
6899
7421
  return existing;
6900
- const db = new Database(join(this.dir, `${tenantId}__${scopeId}.sqlite`));
7422
+ const file = join(this.dir, `${tenantId}__${scopeId}.sqlite`);
7423
+ const db = new Database(file);
7424
+ // WAL is also what the read connection rests on (#1624): a reader sees the last
7425
+ // committed snapshot and never blocks, or is blocked by, the writer.
6901
7426
  db.pragma('journal_mode = WAL');
6902
7427
  db.exec(KERNEL_DDL);
6903
7428
  this.ensureSpineColumns(db);
@@ -6914,7 +7439,9 @@ export class SqliteScopeHost {
6914
7439
  const created = {
6915
7440
  tenantId,
6916
7441
  scopeId,
7442
+ file,
6917
7443
  db,
7444
+ reader: null,
6918
7445
  actor: new ScopeActor(),
6919
7446
  appliedMigrations,
6920
7447
  mintEventId,
@@ -6945,6 +7472,20 @@ function cellToJson(v) {
6945
7472
  * `_substrat_*`" a mechanism rather than a lint rule (#954) — the kernel's own
6946
7473
  * spine writes use `rt.db` directly and never pass through here.
6947
7474
  */
7475
+ /**
7476
+ * The kernel's OWN spine access (#1672) — the same seam as `scopedSql` without `guardSpine`,
7477
+ * because these are the kernel's writes to `_substrat_capabilities`, which module code may
7478
+ * never make. Never handed to module code.
7479
+ */
7480
+ function spineSql(db) {
7481
+ return {
7482
+ query: (sql, params = []) => db.prepare(sql).all(...params),
7483
+ exec: (sql, params = []) => {
7484
+ const info = db.prepare(sql).run(...params);
7485
+ return { changes: info.changes };
7486
+ },
7487
+ };
7488
+ }
6948
7489
  function scopedSql(db) {
6949
7490
  return guardSpine({
6950
7491
  query: (sql, params = []) => db.prepare(sql).all(...params),