@substrat-run/adapter-cloudflare 0.116.0 → 0.117.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/scope-do.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { DurableObject } from 'cloudflare:workers';
2
- import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, toWireFailure, grantRefFromProof, principalId, scopeId as scopeIdOf, tenantId as tenantIdOf, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, MAX_PENDING_SWEEP_RUNS, SWEEP_RUNS_KIND, platformRequest, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, requestFingerprint, substratError, assertReplayableDump, REDRAIN_BATCH, } from '@substrat-run/contracts';
3
- import { ulid, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, PLATFORM_REQUEST_COLUMNS, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, IDEMPOTENCY_DDL, assertIdempotencyKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, JOB_RUN_DDL, jobRunListLimit, } from '@substrat-run/kernel';
2
+ import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, toWireFailure, grantRefFromProof, principalId, scopeId as scopeIdOf, tenantId as tenantIdOf, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, MAX_PENDING_SWEEP_RUNS, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, requestFingerprint, substratError, assertReplayableDump, REDRAIN_BATCH, } from '@substrat-run/contracts';
3
+ import { ulid, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, seatScopeTuple, effectiveRoleGrantQuery, switchSystemSchedules, systemScheduleState, systemSwitchedOff, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexMigrations, searchIndexPlans, NotListable, listIndexMigrations, listIndexPlans, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, IDEMPOTENCY_DDL, assertIdempotencyKey, assertPermissionKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, JOB_RUN_DDL, jobRunListLimit, } from '@substrat-run/kernel';
4
4
  import { isUpgradeRequest, readSubscription, LIVE_FANOUT_LIMIT, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, } from './live-reads.js';
5
5
  import { OperationQueue } from './serialization.js';
6
- import { doScopedSql } from './sql.js';
7
- import { facetEvents, readDeadLetters, readHistory, readInvocation, walkEventCause, walkEventEffects } from '@substrat-run/kernel';
6
+ import { doScopedSql, doSpineSql } from './sql.js';
7
+ import { actorOf, assertNoSecret, CAPABILITY_DDL, CAPABILITY_EXCHANGE_OPERATION, createCapabilityVerbs, exchangeCapability, guardSecrets, mintBecomeCapability, redactSecrets, resolveCapabilitySession, revokeCapabilityAsPlatform, domainEventOf, facetEvents, readDeadLetters, readHistory, readInvocation, readUndrainedOutbox, walkEventCause, walkEventEffects, } from '@substrat-run/kernel';
8
8
  import { createDoTupleChecker, createLocalControlPlaneReader } from './checker.js';
9
9
  /**
10
10
  * The key marking a scope DO whose storage was destroyed (`destroyStorage`).
@@ -310,6 +310,10 @@ const KERNEL_DDL = `
310
310
  ${OUTBOX_ENTITY_INDEX}
311
311
  -- #116: the request-dedupe table, kernel-owned so no vertical migrates for it.
312
312
  ${IDEMPOTENCY_DDL}
313
+ -- #1672: capabilities — authority carried by a secret (a link share), and the sessions
314
+ -- an exchange trades that secret for. Shared with the pure adapter from
315
+ -- @substrat-run/kernel so the two cannot part company; the column comments are there.
316
+ ${CAPABILITY_DDL}
313
317
  `;
314
318
  /**
315
319
  * Workers RPC carries a plain `Error`'s MESSAGE faithfully and nothing else. A custom
@@ -368,27 +372,6 @@ function attachSubject(principal, connectionId) {
368
372
  function isSystemTable(name) {
369
373
  return name.startsWith('_substrat') || name.startsWith('sqlite_');
370
374
  }
371
- /**
372
- * A stored row → the `PlatformRequest` contract shape (JSON columns parsed). The coordinator
373
- * maps the RPC's raw rows the same way in `host.ts`; this copy exists because `ctx.platformRequests`
374
- * (#618) answers INSIDE the DO, where the row never crosses an RPC boundary at all.
375
- */
376
- function rowToPlatformRequest(r) {
377
- return platformRequest.parse({
378
- id: r.id,
379
- kind: r.kind,
380
- payload: JSON.parse(r.payload),
381
- requestedBy: JSON.parse(r.requested_by),
382
- impersonation: r.impersonation == null ? null : JSON.parse(r.impersonation),
383
- status: r.status,
384
- attempts: r.attempts,
385
- lastError: r.last_error,
386
- failure: r.last_failure == null ? null : JSON.parse(r.last_failure),
387
- result: r.result === null ? null : JSON.parse(r.result),
388
- requestedAt: r.requested_at,
389
- settledAt: r.settled_at,
390
- });
391
- }
392
375
  /** SQLite cell → a JSON-safe value: bigints stringify, blobs (ArrayBuffer) read as null. */
393
376
  function cellToJson(v) {
394
377
  if (v == null)
@@ -709,9 +692,11 @@ export function defineScopeDO(modules, bareOps) {
709
692
  * and skips the write when nothing changed.
710
693
  *
711
694
  * `ensureMigrations` memoises its promise, so every later call on a warm DO
712
- * resolves to the SAME `true` without applying anything. Reporting on each of
713
- * those would bill a control-plane RPC per stub mint to store a number that
714
- * has not moved — hence the once-per-instance latch.
695
+ * resolves to the SAME `true` without applying anything — until something
696
+ * clears the memo (`retryMigrations`, `importDump`), which is a fresh pass and
697
+ * may apply. Reporting on each of the cached ones would bill a control-plane
698
+ * RPC per stub mint to store a number that has not moved — hence the
699
+ * once-per-instance latch.
715
700
  */
716
701
  async migrate() {
717
702
  const applied = await this.ensureMigrations();
@@ -767,6 +752,14 @@ export function defineScopeDO(modules, bareOps) {
767
752
  migrationFailure() {
768
753
  return this.lastFailure ? { ...this.lastFailure, applied: this.applied.size } : null;
769
754
  }
755
+ /**
756
+ * This scope's database size in bytes (#1524): `SqlStorage.databaseSize`, which Cloudflare
757
+ * bills on. Its one caller is an on-demand storage reading, never a sweep, because reaching
758
+ * it wakes this DO.
759
+ */
760
+ databaseSize() {
761
+ return this.sql.databaseSize;
762
+ }
770
763
  /**
771
764
  * The PITR bookmarks this scope recorded before migration passes (#286),
772
765
  * newest first — what a backout UI offers as rewind points. Rows taken after
@@ -791,26 +784,26 @@ export function defineScopeDO(modules, bareOps) {
791
784
  }));
792
785
  }
793
786
  /**
794
- * The events not yet shipped to Tier 2 (#1334), oldest first. `ORDER BY id`
795
- * is chronological (ULID) and stable, so a drain resumes where it stopped.
787
+ * The events not yet shipped to Tier 2 (#1334), oldest first — and what the read
788
+ * stepped over (#1636). `ORDER BY id` is chronological (ULID) and stable, so a drain
789
+ * resumes where it stopped.
790
+ *
791
+ * The kernel's read, shared with the pure adapter: a row that will not decode is
792
+ * neither returned nor stamped, and the rows behind it still come back. An object
793
+ * rather than an array because it crosses the RPC — a property on an array would not.
794
+ */
795
+ undrainedEventsRead(limit) {
796
+ return readUndrainedOutbox((offset, count) => this.sql
797
+ .exec(`SELECT * FROM _substrat_outbox WHERE drained_at IS NULL ORDER BY id LIMIT ? OFFSET ?`, count, offset)
798
+ .toArray(), limit);
799
+ }
800
+ /**
801
+ * The same read as a bare array, for a coordinator deployed before
802
+ * `undrainedEventsRead` (#1636) — kept so that pairing still drains, and still steps
803
+ * over a bad row rather than stalling on it. It just cannot say that it did.
796
804
  */
797
805
  undrainedEvents(limit) {
798
- const rows = this.sql
799
- .exec(`SELECT * FROM _substrat_outbox WHERE drained_at IS NULL ORDER BY id LIMIT ?`, limit)
800
- .toArray();
801
- return rows.map((r) => ({
802
- ...this.parseOutboxRow(r),
803
- operation: r.operation ?? null,
804
- version: r.version ?? null,
805
- // #1237 — lifted like the two above, and for the same reason: the column
806
- // exists on the outbox but not on the envelope `parseOutboxRow` returns,
807
- // whose `domainEvent.parse` strips anything it does not declare.
808
- causedBy: r.caused_by ?? null,
809
- // …and the invocation, for the same reason again: a lake that kept cause and
810
- // dropped the call could say what set an event off and never which request did
811
- // it, which is the grouping a trace is built on.
812
- invocationId: r.invocation_id ?? null,
813
- }));
806
+ return this.undrainedEventsRead(limit).events;
814
807
  }
815
808
  /**
816
809
  * Stamp `drained_at` on shipped events (#1334). Idempotent — a re-mark is a no-op,
@@ -1007,13 +1000,30 @@ export function defineScopeDO(modules, bareOps) {
1007
1000
  return rows.sort((a, b) => a.subject.localeCompare(b.subject) || a.relation.localeCompare(b.relation));
1008
1001
  });
1009
1002
  }
1010
- /** Admin scope-tuple write (role assignment / grant scoped to this scope). */
1003
+ /**
1004
+ * Admin scope-tuple write (role assignment / grant scoped to this scope) — the
1005
+ * EXPLICIT grant. `INSERT OR REPLACE`, so it clears a tombstone: a re-grant grants.
1006
+ * Provisioning does not come through here; it seats with `seatTuple` (#1659).
1007
+ */
1011
1008
  async writeTuple(subject, relation, object, expiresAt) {
1012
1009
  await this.queue.enqueue(() => {
1013
1010
  this.sql.exec(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at)
1014
1011
  VALUES (?, ?, ?, ?)`, subject, relation, object, expiresAt);
1015
1012
  });
1016
1013
  }
1014
+ /**
1015
+ * Provisioning's scope-tuple write (#1659): create the row if it is missing, follow the
1016
+ * platform's expiry if it is live, and leave it alone if it was revoked — so a re-run
1017
+ * provision cannot undo an operator's revoke — and seat nothing at all for a module
1018
+ * whose schedule kill switch is off (#1666). `seatScopeTuple` is the statement, shared
1019
+ * with `applyProjection`'s `scopeTuples` and with the pure adapter.
1020
+ */
1021
+ async seatTuple(subject, relation, object, expiresAt) {
1022
+ await this.queue.enqueue(() => {
1023
+ const seat = seatScopeTuple(subject, relation, object, expiresAt);
1024
+ this.sql.exec(seat.sql, ...seat.params);
1025
+ });
1026
+ }
1017
1027
  /**
1018
1028
  * The dispatch capability's admission (#726 remedy B): this delivery may read the
1019
1029
  * attachments of the entity its own spine row names, and no others.
@@ -1103,19 +1113,28 @@ export function defineScopeDO(modules, bareOps) {
1103
1113
  * this scope; the DO uses it for the two-actor stamp and for the read-only
1104
1114
  * bound, and nothing here can be reached without it having been minted.
1105
1115
  */
1106
- impersonation) {
1116
+ impersonation,
1117
+ /**
1118
+ * #1672: the HASH of a capability session token — the coordinator hashed the token
1119
+ * and the plaintext never crosses. Resolved to its capability INSIDE the queued body,
1120
+ * on every call, so a revoke between two calls refuses the second. `principal` is
1121
+ * then a random placeholder that holds nothing, and the reply carries
1122
+ * `capability.honoured`: an old DO that ignored this argument would run the call as
1123
+ * that placeholder, and the coordinator refuses a success without the acknowledgement.
1124
+ */
1125
+ capabilitySession) {
1107
1126
  if (!failureEnvelope) {
1108
1127
  // Legacy path, byte-for-byte what it was: rewrapped so a non-plain error (a
1109
1128
  // ZodError, whose `message` is a getter) still arrives with its message.
1110
1129
  try {
1111
- return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation);
1130
+ return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession);
1112
1131
  }
1113
1132
  catch (err) {
1114
1133
  throw toRpcError(err);
1115
1134
  }
1116
1135
  }
1117
1136
  try {
1118
- return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation);
1137
+ return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession);
1119
1138
  }
1120
1139
  catch (err) {
1121
1140
  // The ONE place the error keeps its structure: flattened here, rebuilt by the
@@ -1127,7 +1146,9 @@ export function defineScopeDO(modules, bareOps) {
1127
1146
  /** The operation path itself. Throws; `invoke` decides how that reaches the caller. */
1128
1147
  async invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions,
1129
1148
  /** K-42: the session, resolved coordinator-side. See `invoke` above. */
1130
- impersonation) {
1149
+ impersonation,
1150
+ /** #1672: a capability session's hash. See `invoke` above. */
1151
+ capabilitySession) {
1131
1152
  await this.ensureMigrations();
1132
1153
  const handler = this.operations.get(operation);
1133
1154
  // `not_found`, not a bare throw (#113): every vertical hand-matched this message
@@ -1186,7 +1207,7 @@ export function defineScopeDO(modules, bareOps) {
1186
1207
  // The subject a key is scoped to — the same three-way read `recordDenial`
1187
1208
  // makes below, hoisted because both need it. A key belongs to whoever sent
1188
1209
  // it: two principals choosing `1` must not reach each other's response.
1189
- const idempotencySubjectRef = systemModuleId
1210
+ let idempotencySubjectRef = systemModuleId
1190
1211
  ? { kind: 'system', id: systemModuleId }
1191
1212
  : connectionId
1192
1213
  ? { kind: 'connection', id: connectionId }
@@ -1210,6 +1231,19 @@ export function defineScopeDO(modules, bareOps) {
1210
1231
  // one call. Same placement as the SQLite adapter's actor task, for this reason.
1211
1232
  this.invocationId = invokeOptions?.invocationId ?? null;
1212
1233
  try {
1234
+ // #1672: the capability session, resolved on EVERY call and here — inside the queued
1235
+ // body, the one region where this call holds the DO to itself — so nothing can
1236
+ // revoke between this read and the transaction. Refuses a stale session, a revoked
1237
+ // or expired capability and an operation off its allowlist before anything opens;
1238
+ // none of those is a K-35 denial (no key was checked).
1239
+ let capabilityId;
1240
+ if (capabilitySession !== undefined) {
1241
+ capabilityId = resolveCapabilitySession(doSpineSql(this.sql), capabilitySession, instant.parse(new Date().toISOString()), operation);
1242
+ idempotencySubjectRef = { kind: 'capability', id: capabilityId };
1243
+ }
1244
+ // #1672: the secrets this call mints — withheld from its idempotency recording, and
1245
+ // what the tripwire on its writes looks for.
1246
+ const minted = [];
1213
1247
  /**
1214
1248
  * #938: the outbox's high-water mark BEFORE this call wrote anything, so the
1215
1249
  * post-commit fan-out can name exactly the events this call (and the consumers
@@ -1252,7 +1286,7 @@ export function defineScopeDO(modules, bareOps) {
1252
1286
  // emitted events back as one — verified across `await` in workerd.
1253
1287
  try {
1254
1288
  await this.ctx.storage.transaction(async () => {
1255
- const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemModuleId, signals, impersonation, operation);
1289
+ const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemModuleId, signals, impersonation, operation, capabilityId, minted);
1256
1290
  // #116: a retry is answered from the recording, and nothing else runs
1257
1291
  // — not the guards, not the handler, not the permission check inside
1258
1292
  // it. Keyed by SUBJECT, so a caller only ever reaches its own
@@ -1293,7 +1327,9 @@ export function defineScopeDO(modules, bareOps) {
1293
1327
  // it describes. The prune rides along, on the only path that adds a row.
1294
1328
  if (idempotencyKey !== undefined && fingerprint !== undefined) {
1295
1329
  const at = new Date().toISOString();
1296
- const record = idempotencyRecordStatement(idempotencySubjectRef, idempotencyKey, operation, fingerprint, result, committedVersion, at);
1330
+ const record = idempotencyRecordStatement(idempotencySubjectRef, idempotencyKey, operation, fingerprint,
1331
+ // #1672: a replay of a mint returns the placeholder, never the secret.
1332
+ redactSecrets(result, minted), committedVersion, at);
1297
1333
  this.sql.exec(record.sql, ...record.params);
1298
1334
  const prune = idempotencyPruneStatement(at);
1299
1335
  this.sql.exec(prune.sql, ...prune.params);
@@ -1316,6 +1352,7 @@ export function defineScopeDO(modules, bareOps) {
1316
1352
  result,
1317
1353
  platformRequests: 0,
1318
1354
  impersonation: { honoured: true },
1355
+ ...(capabilitySession !== undefined ? { capability: { honoured: true } } : {}),
1319
1356
  ...(idempotencyKey !== undefined
1320
1357
  ? { idempotency: { keyHonoured: true, replayed } }
1321
1358
  : {}),
@@ -1332,11 +1369,9 @@ export function defineScopeDO(modules, bareOps) {
1332
1369
  // K-35: the transaction has rolled back; record a refused check now, as its own
1333
1370
  // write (outside that transaction), so the denial survives the rollback.
1334
1371
  if (err instanceof PermissionDenied) {
1335
- this.recordDenial(systemModuleId
1336
- ? { kind: 'system', id: systemModuleId }
1337
- : connectionId
1338
- ? { kind: 'connection', id: connectionId }
1339
- : { kind: 'principal', id: principal }, tenantId, operation, err,
1372
+ this.recordDenial(
1373
+ // The subject the call acted as — the capability's, once resolved above.
1374
+ idempotencySubjectRef, tenantId, operation, err,
1340
1375
  // Inside the queued body, which is the one region where this call holds
1341
1376
  // the DO to itself — so the field is this call's own (#1237).
1342
1377
  this.invocationId, impersonation);
@@ -1362,6 +1397,8 @@ export function defineScopeDO(modules, bareOps) {
1362
1397
  result,
1363
1398
  platformRequests: signals.platformRequests,
1364
1399
  ...(impersonation ? { impersonation: { honoured: true } } : {}),
1400
+ // #1672: the acknowledgement the coordinator's skew check reads — see `invoke`.
1401
+ ...(capabilitySession !== undefined ? { capability: { honoured: true } } : {}),
1365
1402
  // The acknowledgement the coordinator's skew check reads (#116), on the
1366
1403
  // same reasoning as `ifMatchChecked` below and with a sharper failure: a
1367
1404
  // DO too old to know about keys would EXECUTE THE OPERATION AGAIN and
@@ -1841,19 +1878,106 @@ export function defineScopeDO(modules, bareOps) {
1841
1878
  return record;
1842
1879
  });
1843
1880
  }
1881
+ /** The kernel's schedule-switch SQL (#1666), over this DO's storage. */
1882
+ switchSql() {
1883
+ return {
1884
+ all: (sql, ...params) => this.sql.exec(sql, ...params).toArray(),
1885
+ run: (sql, ...params) => {
1886
+ this.sql.exec(sql, ...params);
1887
+ },
1888
+ };
1889
+ }
1844
1890
  /**
1845
- * Whether this scope holds a live `system:<moduleId>` grant (#383) — the switch
1846
- * that decides if a module's schedules run here at all. Absent on a foreign
1847
- * vertical's scope, or after a per-tenant revoke, so the sweep skips it quietly.
1891
+ * Where a module's schedules stand on this scope (#383, #1666) — the kernel's
1892
+ * `systemScheduleState`, the predicate the pure adapter runs too: `on` with a live
1893
+ * `system:<moduleId>` grant, `off` while the kill switch's marker is live, whatever
1894
+ * else is, and `ungranted` on a scope that never ran the module (a foreign vertical's,
1895
+ * which the sweep skips quietly).
1896
+ */
1897
+ async systemScheduleState(moduleId) {
1898
+ return systemScheduleState(this.switchSql(), moduleId, new Date().toISOString());
1899
+ }
1900
+ /**
1901
+ * The pre-#1666 read, kept for ONE reason: a coordinator a deploy behind this DO still
1902
+ * calls it. It answers the new question, not the old one — the old predicate counted any
1903
+ * live `system:` tuple, and the kill switch's OFF marker is one, so an old coordinator
1904
+ * asking the old question would run a switched-off scope's schedules.
1848
1905
  */
1849
1906
  async hasSystemGrant(moduleId) {
1850
- const now = new Date().toISOString();
1851
- const row = this.sql
1852
- .exec(`SELECT 1 FROM _substrat_tuples
1853
- WHERE subject = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)
1854
- LIMIT 1`, `system:${moduleId}`, now)
1855
- .toArray()[0];
1856
- return row !== undefined;
1907
+ return (await this.systemScheduleState(moduleId)) === 'on';
1908
+ }
1909
+ /**
1910
+ * `grantToSystem`'s scope-level write (#1666): the explicit grant — `INSERT OR REPLACE`,
1911
+ * as `writeTuple` — EXCEPT while the module's schedule kill switch is off, when it writes
1912
+ * nothing and answers `false`. The check and the write are one queued unit, so no switch
1913
+ * can move between them. Restore is the lever; a grant is not.
1914
+ */
1915
+ async writeSystemGrant(moduleId, relation, object, expiresAt) {
1916
+ return this.queue.enqueue(() => {
1917
+ if (systemSwitchedOff(this.switchSql(), moduleId))
1918
+ return false;
1919
+ this.sql.exec(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at)
1920
+ VALUES (?, ?, ?, ?)`, `system:${moduleId}`, relation, object, expiresAt);
1921
+ return true;
1922
+ });
1923
+ }
1924
+ /**
1925
+ * Move a module's schedule switch on this scope (#1666) — the kernel's
1926
+ * `switchSystemSchedules`, serialized on the queue with every other tuple write and run
1927
+ * as one `transactionSync`, so its reads and writes are one unit.
1928
+ */
1929
+ async switchSystemSchedules(moduleId, scopeId, to, at) {
1930
+ return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => switchSystemSchedules(this.switchSql(), { moduleId, scopeId, to, at })));
1931
+ }
1932
+ /**
1933
+ * The exchange (#1672) — a capability's secret traded for a session, or for the
1934
+ * principal a `become` capability yields. Runs the kernel's `exchangeCapability`, the
1935
+ * function the pure adapter runs, in this DO: one queued body and one storage
1936
+ * transaction, so the use it takes and the `capability.exercised` event recording it
1937
+ * commit together or not at all; the event's consumers then settle as an invoke's do.
1938
+ *
1939
+ * The secret itself reaches this DO — it must, to be hashed and looked up — and goes
1940
+ * no further: nothing here stores or returns it.
1941
+ */
1942
+ async exchangeCapability(secret, tenantId, scopeId, mode) {
1943
+ await this.ensureMigrations();
1944
+ return await this.queue.enqueue(async () => {
1945
+ const liveSince = this.liveHighWaterMark();
1946
+ // ONE instant for the whole exchange: the row's `last_used_at`, the session's times
1947
+ // and the event's `occurredAt` are one fact, and two clock reads could disagree.
1948
+ const now = instant.parse(new Date().toISOString());
1949
+ let outcome = null;
1950
+ await this.ctx.storage.transaction(async () => {
1951
+ outcome = await exchangeCapability({
1952
+ sql: doSpineSql(this.sql),
1953
+ now,
1954
+ // Stamped `{ capability }` by the ordinary emit path, under the exchange's own
1955
+ // pseudo-operation name — the actor is the capability, as on every event it
1956
+ // goes on to cause.
1957
+ emit: (capability, event) => this.operationContext(principalId.parse(ulid()), tenantId, scopeId, undefined, undefined, undefined, undefined, undefined, CAPABILITY_EXCHANGE_OPERATION, capability, [], now).emit(event),
1958
+ }, secret, mode);
1959
+ });
1960
+ if (outcome)
1961
+ await this.settleCommitted(tenantId, scopeId, liveSince, null);
1962
+ return outcome;
1963
+ });
1964
+ }
1965
+ /**
1966
+ * The platform's mint (#1672, `HostAdmin.mintCapability`) — a `become` capability,
1967
+ * serialized on the queue with every other spine write. One INSERT, so it needs no
1968
+ * transaction of its own; the hash is computed before it.
1969
+ */
1970
+ async mintBecomeCapability(input, actor) {
1971
+ await this.ensureMigrations();
1972
+ return await this.queue.enqueue(() => mintBecomeCapability(doSpineSql(this.sql), input, actor, instant.parse(new Date().toISOString())));
1973
+ }
1974
+ /**
1975
+ * The platform's revoke (#1672, `HostAdmin.revokeCapability`) — of any capability in
1976
+ * this scope. Returns the record as it stood before, for the admin log, or `null`.
1977
+ */
1978
+ async revokeCapabilityAsPlatform(id, actor) {
1979
+ await this.ensureMigrations();
1980
+ return await this.queue.enqueue(() => this.ctx.storage.transactionSync(() => revokeCapabilityAsPlatform(doSpineSql(this.sql), id, actor, instant.parse(new Date().toISOString()))) ?? null);
1857
1981
  }
1858
1982
  /**
1859
1983
  * The last time a schedule's operation ran on this scope (#383), or null.
@@ -2173,8 +2297,15 @@ export function defineScopeDO(modules, bareOps) {
2173
2297
  *
2174
2298
  * "Not yet consumed" means never attempted, or retrying and now due (#100).
2175
2299
  * Terminal rows — delivered or dead-lettered — are excluded by the join.
2176
- */
2177
- pendingExecutorEvents(deliveryId, eventType) {
2300
+ *
2301
+ * Decoded per row (#1636). A row that will not decode comes back in `undecodable`,
2302
+ * never as an event: the coordinator journals it as a dead letter, and its handler is
2303
+ * never handed an event built from stand-ins. The whole list used to be one
2304
+ * `rows.map(decode)`, so one bad row threw the executor's pending list on every pass.
2305
+ * The journal write stays on the coordinator, beside every other attempt it records —
2306
+ * this stays a read.
2307
+ */
2308
+ pendingExecutorDeliveries(deliveryId, eventType) {
2178
2309
  const rows = this.sql
2179
2310
  .exec(`SELECT o.* FROM _substrat_outbox o
2180
2311
  LEFT JOIN _substrat_deliveries d
@@ -2184,7 +2315,26 @@ export function defineScopeDO(modules, bareOps) {
2184
2315
  OR (d.next_attempt_at IS NOT NULL AND d.next_attempt_at <= ?))
2185
2316
  ORDER BY o.id`, deliveryId, eventType, new Date().toISOString())
2186
2317
  .toArray();
2187
- return rows.map((r) => this.parseOutboxRow(r));
2318
+ const events = [];
2319
+ const undecodable = [];
2320
+ for (const r of rows) {
2321
+ try {
2322
+ events.push(domainEventOf(r));
2323
+ }
2324
+ catch (err) {
2325
+ undecodable.push({ eventId: r.id, error: String(err) });
2326
+ }
2327
+ }
2328
+ return { events, undecodable };
2329
+ }
2330
+ /**
2331
+ * The same read as a bare list of events, for a coordinator deployed before
2332
+ * `pendingExecutorDeliveries` (#1636). An undecodable row is left out rather than
2333
+ * thrown, so that pairing still delivers the rows behind it; the next coordinator
2334
+ * dead-letters it.
2335
+ */
2336
+ pendingExecutorEvents(deliveryId, eventType) {
2337
+ return this.pendingExecutorDeliveries(deliveryId, eventType).events;
2188
2338
  }
2189
2339
  /**
2190
2340
  * Journal one executor attempt (#100). `error` null means delivered;
@@ -2273,12 +2423,21 @@ export function defineScopeDO(modules, bareOps) {
2273
2423
  * `result` is COALESCE'd so a value written on an earlier pass (e.g. a minted sibling scope id,
2274
2424
  * for two-phase idempotency) survives a null on retry. `attempts` bumps each settle; `settled_at`
2275
2425
  * is set only on a terminal outcome.
2426
+ *
2427
+ * **Compare-and-set on `pending` (#1600 review).** The drain reads pending rows, runs a
2428
+ * handler, then settles — and between the read and the settle a subject erasure can redact
2429
+ * the row. Settling by `id` alone let that stale pass overwrite the redaction and write a
2430
+ * provider's reply, which can quote the person, back into `last_error`. Nothing legitimate
2431
+ * is refused: `pendingPlatformRequests` returns only pending rows, so every settle targets
2432
+ * one that was pending when it was read. A settle that finds the row already terminal does
2433
+ * nothing, deliberately silently — throwing would make the drain's blanket catch retry a
2434
+ * row that is correctly over.
2276
2435
  */
2277
2436
  settlePlatformRequest(id, status, result, lastError, lastFailure = null) {
2278
2437
  this.sql.exec(`UPDATE _substrat_platform_requests
2279
2438
  SET status = ?, result = COALESCE(?, result), last_error = ?, last_failure = ?,
2280
2439
  attempts = attempts + 1, settled_at = ?
2281
- WHERE id = ?`, status, result, lastError, lastFailure, status === 'pending' ? null : new Date().toISOString(), id);
2440
+ WHERE id = ? AND status = 'pending'`, status, result, lastError, lastFailure, status === 'pending' ? null : new Date().toISOString(), id);
2282
2441
  }
2283
2442
  /**
2284
2443
  * Turn one connector delivery into a `connector:<provider>` platform intent (#574
@@ -2496,6 +2655,9 @@ export function defineScopeDO(modules, bareOps) {
2496
2655
  * `destScopeId` is the scope being written INTO. A dump carries scope-level tuples
2497
2656
  * naming the scope it was captured from, so restoring one anywhere else needs them
2498
2657
  * re-pointed — see `rewriteScopeTuples`.
2658
+ *
2659
+ * What the dump did NOT carry is rebuilt by the next migration pass, which is why
2660
+ * this ends by forgetting the memoised one (#1589) — see the tail of the method.
2499
2661
  */
2500
2662
  /**
2501
2663
  * The additive spine-column migrations. KERNEL_DDL is all IF NOT EXISTS, so a
@@ -2699,6 +2861,29 @@ export function defineScopeDO(modules, bareOps) {
2699
2861
  .toArray()) {
2700
2862
  this.applied.add(`${row.module_id}@${row.version}`);
2701
2863
  }
2864
+ // …and forget that this INSTANCE ever ran a migration pass (#1589). Refreshing
2865
+ // the set above is not enough on its own: `ensureMigrations` memoises its
2866
+ // promise, so a warm DO answers "already migrated" from the cache and never
2867
+ // reads the set again. A restore replays only what the dump carries, so a dump
2868
+ // that omits a module's tables — a world that keeps part of the spine
2869
+ // elsewhere, a targeted repair supplying only the tables being fixed — leaves
2870
+ // them dropped and never rebuilt, and the next operation touching one fails
2871
+ // with a bare `no such table` until an eviction or `retryMigrations` resets the
2872
+ // latch. The pure host has no such memo (it re-reads `appliedMigrations` on
2873
+ // every pass), so this is the line that makes the two adapters agree; without
2874
+ // it the divergence is invisible to dev, CI and self-host, which is the #969
2875
+ // class exactly. Same two fields `retryMigrations` clears, for the same reason:
2876
+ // the next pass has to be a fresh one. Already-journaled versions are skipped
2877
+ // by the `applied` set and the in-transaction re-check, so a dump that DID
2878
+ // carry its tables re-applies nothing.
2879
+ //
2880
+ // `schemaVersionReported` is deliberately left set, as `retryMigrations` leaves
2881
+ // it. `migrate()` reports only when a pass APPLIED something, and a pass that
2882
+ // applies after this reset ends with every code-defined migration journaled —
2883
+ // the same `applied.size` the first pass already reported. A dump whose frontier
2884
+ // is complete applies nothing, so there is nothing to report either way.
2885
+ this.migrationPromise = undefined;
2886
+ this.lastFailure = null;
2702
2887
  }
2703
2888
  /**
2704
2889
  * Re-point scope-level tuples at the scope they now live in.
@@ -2760,12 +2945,23 @@ export function defineScopeDO(modules, bareOps) {
2760
2945
  * and transaction facts remain". A timeline still shows that something happened, to
2761
2946
  * what, and when; it no longer shows who, or what was said about them.
2762
2947
  *
2948
+ * **Two tables, one verb (#1600).** The outbox is not the only place the spine holds
2949
+ * an event's payload: this host is the CP-less one, so every connector delivery it
2950
+ * cannot run becomes a `connector:<provider>` intent carrying the whole event, and
2951
+ * nothing ever deletes those rows. Redacting one table and not the other left the
2952
+ * name in the live database and in every copy taken from it afterwards. One RPC
2953
+ * rather than two for `routeExecutorEventToPlatform`'s reason — a crash cannot land
2954
+ * half an erasure — and both halves are idempotent anyway, so a retry converges.
2955
+ *
2763
2956
  * This is the one sanctioned write that mutates the outbox. It is kernel code, not
2764
2957
  * module code, and an erasure request is precisely the case the append-only rule has
2765
2958
  * to yield to — the alternative is telling a data subject that the spine's convenience
2766
2959
  * outranks their Article 17 right.
2767
2960
  */
2768
2961
  async redactSubject(subjectId) {
2962
+ // One instant for the whole erasure — the intent tombstones must not disagree with
2963
+ // each other about when a person was erased.
2964
+ const at = new Date().toISOString();
2769
2965
  const doomed = this.sql
2770
2966
  .exec(`SELECT id FROM _substrat_outbox
2771
2967
  WHERE subject_id = ? AND pii_class != 'none' AND payload IS NOT NULL`, subjectId)
@@ -2773,7 +2969,30 @@ export function defineScopeDO(modules, bareOps) {
2773
2969
  for (const id of doomed) {
2774
2970
  this.sql.exec('UPDATE _substrat_outbox SET payload = NULL WHERE id = ?', id);
2775
2971
  }
2776
- return doomed.length;
2972
+ return { events: doomed.length, intents: this.redactSubjectIntents(subjectId, at) };
2973
+ }
2974
+ /**
2975
+ * The intent-journal half of `redactSubject` (#1600) — the SQLite adapter's twin.
2976
+ *
2977
+ * Row-by-row rather than one `UPDATE … WHERE`, because the decision is structural and
2978
+ * SQL cannot make it: an intent payload is opaque JSON with no `pii_class` column to
2979
+ * test, so the SQL narrows to rows that could possibly match and the kernel predicate
2980
+ * decides. Query, predicate and statement all come from the kernel, so this adapter
2981
+ * and the pure one cannot drift about what a redacted intent is.
2982
+ */
2983
+ redactSubjectIntents(subjectId, at) {
2984
+ const q = platformRequestRedactionQuery(subjectId);
2985
+ const candidates = this.sql
2986
+ .exec(q.sql, ...q.params)
2987
+ .toArray();
2988
+ let redacted = 0;
2989
+ for (const row of candidates) {
2990
+ if (!intentPayloadCarriesSubject(row.payload, subjectId))
2991
+ continue;
2992
+ this.sql.exec(PLATFORM_REQUEST_REDACTION_SQL, ...platformRequestRedactionParams(row.id, subjectId, at));
2993
+ redacted += 1;
2994
+ }
2995
+ return redacted;
2777
2996
  }
2778
2997
  // -- event dispatch (port of dispatch) ------------------------------------
2779
2998
  /**
@@ -2820,7 +3039,21 @@ export function defineScopeDO(modules, bareOps) {
2820
3039
  ORDER BY o.id`, consumer.eventType, mod.id)
2821
3040
  .toArray();
2822
3041
  for (const row of rows) {
2823
- const event = this.parseOutboxRow(row);
3042
+ let event;
3043
+ try {
3044
+ event = domainEventOf(row);
3045
+ }
3046
+ catch (err) {
3047
+ // #1636: dead-letter an event that does not decode, exactly as a failed
3048
+ // handler is — the decode sat ABOVE the `try` below, so one bad row halted
3049
+ // every event of this type behind it, on every pass. The consumer is never
3050
+ // handed it: an event built from stand-ins is not one it may act on. Only
3051
+ // the decode is caught; the journal write is not.
3052
+ this.sql.exec(`INSERT INTO _substrat_deliveries
3053
+ (event_id, consumer_module, delivered_at, error, invocation_id)
3054
+ VALUES (?, ?, ?, ?, ?)`, row.id, mod.id, new Date().toISOString(), String(err), invocationId);
3055
+ continue;
3056
+ }
2824
3057
  // #1237: anything this consumer emits was emitted BECAUSE of this event
2825
3058
  // — the step a backwards walk used to stop dead at, since a consumer
2826
3059
  // emit records no operation either.
@@ -2891,11 +3124,7 @@ export function defineScopeDO(modules, bareOps) {
2891
3124
  // no permission key and is left to the module.
2892
3125
  if (!err.permission || !err.node)
2893
3126
  return;
2894
- const actor = subject.kind === 'system'
2895
- ? { system: subject.id }
2896
- : subject.kind === 'connection'
2897
- ? { connection: subject.id }
2898
- : subject.id;
3127
+ const actor = actorOf(subject);
2899
3128
  this.sql.exec(`INSERT INTO _substrat_denials
2900
3129
  (id, actor, permission, tenant_id, scope_id, operation, impersonation,
2901
3130
  invocation_id, at)
@@ -2903,29 +3132,6 @@ export function defineScopeDO(modules, bareOps) {
2903
3132
  // #1525: the call this refusal belongs to, as the caller named it.
2904
3133
  invocationId, new Date().toISOString());
2905
3134
  }
2906
- parseOutboxRow(row) {
2907
- return domainEvent.parse({
2908
- id: row.id,
2909
- type: row.type,
2910
- schemaVersion: row.schema_version,
2911
- occurredAt: row.occurred_at,
2912
- tenantId: row.tenant_id,
2913
- scopeId: row.scope_id,
2914
- actor: JSON.parse(row.actor),
2915
- entity: { entityType: row.entity_type, entityId: row.entity_id },
2916
- piiClass: row.pii_class,
2917
- ...(row.subject_id ? { subjectId: row.subject_id } : {}),
2918
- ...(row.authorization ? { authorization: JSON.parse(row.authorization) } : {}),
2919
- // K-42: the stamp survives the read, so a consumer's event and an executor's
2920
- // are the same fact the stored row is. Absent rather than null when nobody
2921
- // was impersonating, because `DomainEvent.impersonation` is optional — the
2922
- // shape module code never sees is also the shape it cannot branch on.
2923
- ...(row.impersonation ? { impersonation: JSON.parse(row.impersonation) } : {}),
2924
- // #1231: absent rather than null, the same shape rule as the stamp above.
2925
- ...(row.operation ? { operation: row.operation } : {}),
2926
- payload: row.payload === null ? undefined : JSON.parse(row.payload),
2927
- });
2928
- }
2929
3135
  // -- operation context (port of operationContext) -------------------------
2930
3136
  operationContext(principal, tenantId, scopeId, systemActor, connectionId, systemModuleId,
2931
3137
  /** #458: per-invoke tally of `ctx.requestPlatform` calls; absent for consumer dispatch. */
@@ -2943,7 +3149,28 @@ export function defineScopeDO(modules, bareOps) {
2943
3149
  * onto every event it emits. Absent for consumer dispatch: a consumer runs on
2944
3150
  * behalf of no operation, and the emitted row's NULL says so.
2945
3151
  */
2946
- operation) {
3152
+ operation,
3153
+ /**
3154
+ * #1672: set when the caller holds a CAPABILITY session — already resolved from its
3155
+ * hash inside the queued body, before the transaction. Mutually exclusive with
3156
+ * `connectionId` and `systemModuleId`; `principal` is then a placeholder that the
3157
+ * subject below never reads.
3158
+ */
3159
+ capabilityId,
3160
+ /**
3161
+ * #1672: the secrets `ctx.capabilities.mint` hands out during this invocation. The
3162
+ * caller owns the array (it withholds them from the idempotency recording); this
3163
+ * context appends to it and holds `ctx.sql`, `ctx.emit` and `ctx.requestPlatform` to
3164
+ * it — the tripwire that catches a module persisting one by accident (not a boundary
3165
+ * against one that means to; see `assertNoSecret`).
3166
+ */
3167
+ minted = [],
3168
+ /**
3169
+ * #1672: the instant to stamp, when the caller already read one. The exchange passes
3170
+ * the instant its row write used, so the `capability.exercised` event's `occurredAt`
3171
+ * and the row's `last_used_at` are the same value — a second read could disagree.
3172
+ */
3173
+ instantOverride) {
2947
3174
  const checker = this.checker;
2948
3175
  const relations = this.relations;
2949
3176
  const searchPlans = this.searchPlans;
@@ -2957,20 +3184,18 @@ export function defineScopeDO(modules, bareOps) {
2957
3184
  * on, and it holds identically here: `ctx.now()`, every `occurredAt` and
2958
3185
  * every `requested_at` in one operation are the same value.
2959
3186
  */
2960
- const at = instant.parse(new Date().toISOString());
3187
+ const at = instantOverride ?? instant.parse(new Date().toISOString());
2961
3188
  // The permission subject and the derived event actor for a NON-override caller
2962
3189
  // (#383/#97): a scheduled module, a connection, or a person. `systemActor` (the
2963
3190
  // override, used only by consumer dispatch) stays a separate bypass path below.
2964
- const subject = systemModuleId
2965
- ? { kind: 'system', id: systemModuleId }
2966
- : connectionId
2967
- ? { kind: 'connection', id: connectionId }
2968
- : { kind: 'principal', id: principal };
2969
- const derivedActor = subject.kind === 'system'
2970
- ? { system: subject.id }
2971
- : subject.kind === 'connection'
2972
- ? { connection: subject.id }
2973
- : principal;
3191
+ const subject = capabilityId
3192
+ ? { kind: 'capability', id: capabilityId }
3193
+ : systemModuleId
3194
+ ? { kind: 'system', id: systemModuleId }
3195
+ : connectionId
3196
+ ? { kind: 'connection', id: connectionId }
3197
+ : { kind: 'principal', id: principal };
3198
+ const derivedActor = actorOf(subject);
2974
3199
  // #304: entitlement reads pick the same local-vs-RPC reader the permission checker
2975
3200
  // uses (projected scope → local table; console-managed → CP over RPC), resolved per
2976
3201
  // call so a scope that flips to 'local' is picked up without rebuilding the context.
@@ -2999,7 +3224,10 @@ export function defineScopeDO(modules, bareOps) {
2999
3224
  // Lifted so `grant` reuses the SAME check the operation itself passes —
3000
3225
  // a delegation check that could differ from the operation's would be a
3001
3226
  // second opinion about what the caller holds.
3002
- const runCheck = async (permission, entity) => {
3227
+ const runCheck = async (unparsed, entity) => {
3228
+ // #1642: parsed before the system actor's early return, which never reaches
3229
+ // the checker — a cast key would otherwise become that path's proof relation.
3230
+ const permission = assertPermissionKey(unparsed);
3003
3231
  if (systemActor) {
3004
3232
  return {
3005
3233
  allowed: true,
@@ -3034,15 +3262,19 @@ export function defineScopeDO(modules, bareOps) {
3034
3262
  }
3035
3263
  return checker.covers(subject, role.permissions, { tenantId, scopeId });
3036
3264
  };
3037
- return {
3265
+ const ctxRef = {
3038
3266
  tenantId,
3039
3267
  scopeId,
3040
- principal,
3041
- sql: doScopedSql(sql),
3268
+ // #1672: a capability's own id stands in so the type holds — it is not a person, and
3269
+ // the event actor says what it is instead. Every other door passes its own value.
3270
+ principal: capabilityId ? capabilityId : principal,
3271
+ sql: guardSecrets(doScopedSql(sql), minted),
3042
3272
  now: () => at,
3043
3273
  emit: (event) => {
3044
3274
  assertImpersonationWrites(impersonation, 'ctx.emit');
3045
3275
  const parsed = domainEventInput.parse(event);
3276
+ // #1672: the COMPLETE parsed event — entity id, type and subject as well as payload.
3277
+ assertNoSecret('ctx.emit', parsed, minted);
3046
3278
  const full = domainEvent.parse({
3047
3279
  ...parsed,
3048
3280
  // #956: from the operation's instant, not a second reading of the clock.
@@ -3079,6 +3311,8 @@ export function defineScopeDO(modules, bareOps) {
3079
3311
  requestPlatform: (request) => {
3080
3312
  assertImpersonationWrites(impersonation, 'ctx.requestPlatform');
3081
3313
  const input = platformRequestInput.parse(request);
3314
+ // #1672: the COMPLETE parsed request — its `kind` is persisted as surely as its payload.
3315
+ assertNoSecret('ctx.requestPlatform', input, minted);
3082
3316
  // #1474: a platform-authored kind (`sweep-runs`) never comes from module code —
3083
3317
  // the sweeper enqueues it through `enqueueSweepRuns`, which does not pass here.
3084
3318
  assertModuleEnqueueableKind(input.kind);
@@ -3103,7 +3337,9 @@ export function defineScopeDO(modules, bareOps) {
3103
3337
  // predicate is needed or possible: the DO IS the scope.
3104
3338
  platformRequests: (filter) => {
3105
3339
  const q = platformRequestHistoryQuery(filter);
3106
- return sql.exec(q.sql, ...q.params).toArray().map(rowToPlatformRequest);
3340
+ // The kernel's decoder, the one the coordinator maps the RPC's rows with (#1588):
3341
+ // tolerant, so one undecodable row cannot hide this scope's other intents from it.
3342
+ return sql.exec(q.sql, ...q.params).toArray().map(platformRequestOf);
3107
3343
  },
3108
3344
  // #901. Mirror of the pure adapter, and the reason the contract suite
3109
3345
  // runs on both: the query is ordinary SQL, but it is only a seek rather
@@ -3182,6 +3418,22 @@ export function defineScopeDO(modules, bareOps) {
3182
3418
  sql.exec(`DELETE FROM _substrat_tuples WHERE subject = ? AND relation = ? AND object = ?`, `principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`);
3183
3419
  },
3184
3420
  atomic: createAtomic(runSub, { passed, signals }),
3421
+ // #1672: mint / revoke / list, written once in the kernel — the pure adapter hands
3422
+ // the same function the same four things. The raw spine seam (the kernel's own write
3423
+ // to `_substrat_capabilities`), the operation's OWN check, and `ctx.emit`. A
3424
+ // consumer's override actor is passed as the system actor it is, so a consumer
3425
+ // cannot mint: its checks allow unconditionally, which would make "the minter holds
3426
+ // it" vacuous.
3427
+ capabilities: createCapabilityVerbs({
3428
+ sql: doSpineSql(sql),
3429
+ subject: systemActor ? { kind: 'system', id: systemActor.system } : subject,
3430
+ now: at,
3431
+ check: runCheck,
3432
+ emit: (event) => ctxRef.emit(event),
3433
+ isOperation: (name) => this.operations.has(name),
3434
+ assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
3435
+ minted,
3436
+ }),
3185
3437
  link: (child, parent) => {
3186
3438
  assertImpersonationWrites(impersonation, 'ctx.link');
3187
3439
  const allowed = relations.get(child.entityType);
@@ -3215,6 +3467,7 @@ export function defineScopeDO(modules, bareOps) {
3215
3467
  return sealTo({ keyId: row.key_id, publicKey: row.public_key }, plaintext);
3216
3468
  },
3217
3469
  };
3470
+ return ctxRef;
3218
3471
  }
3219
3472
  /** True once this scope has had entitlements projected at least once (#304) — the switch
3220
3473
  * from trust-upstream to strict fail-closed entitlement enforcement on the local path. */
@@ -3317,11 +3570,16 @@ export function defineScopeDO(modules, bareOps) {
3317
3570
  * while passing a list — even `[]` — full-replaces them. This keeps pre-#304 callers
3318
3571
  * from silently wiping a scope's entitlements. */
3319
3572
  entitlements,
3320
- /** Scope-level tuples (e.g. the owner's role grant at provision) upserted into
3573
+ /** Scope-level tuples (e.g. the owner's role grant at provision) seated into
3321
3574
  * `_substrat_tuples` in this SAME transaction, additively (#332). Preserve-on-undefined:
3322
3575
  * omitting it leaves existing scope tuples untouched, so a role-only re-projection keeps
3323
3576
  * the owner grant. Passing them here (rather than a follow-up `writeTuple`) is what makes
3324
- * provision atomic — the grant and the enforcement flip land together or not at all. */
3577
+ * provision atomic — the grant and the enforcement flip land together or not at all.
3578
+ *
3579
+ * Seated, not replaced (#1659): a missing tuple is created, a revoked one stays revoked.
3580
+ * `lockout_reseat` marks the one exception — the owner-of-record's seat, which is
3581
+ * re-seated even over a revoke when the scope would otherwise hold no effective role
3582
+ * grant (`hasEffectiveRoleGrant`). */
3325
3583
  scopeTuples,
3326
3584
  /** The tenant's identity links (#406) — projected alongside the rest so a CP-less
3327
3585
  * vertical's auth adapter resolves logins locally. Same preserve-on-undefined
@@ -3396,18 +3654,39 @@ export function defineScopeDO(modules, bareOps) {
3396
3654
  // — NOT a full replace — so existing scope tuples are preserved. This is what keeps a
3397
3655
  // scope from ever being left "roles projected, permission_source=local, zero tuples" by
3398
3656
  // a write that lands the projection but drops before a follow-up owner grant.
3657
+ //
3658
+ // #1659: SEATED, so a reconcile creates what is missing and leaves a revoke alone. It
3659
+ // used to be `INSERT OR REPLACE … revoked_at = NULL`, which undid an operator's revoke
3660
+ // of the owner seat or of a `system:` schedule grant on the next reconcile. And a
3661
+ // module switched off (#1666) gets no `system:` grant seated, new or old.
3399
3662
  for (const st of scopeTuples ?? []) {
3400
- this.sql.exec(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at, revoked_at)
3401
- VALUES (?, ?, ?, ?, NULL)`, st.subject, st.relation, st.object, st.expires_at);
3663
+ const seat = seatScopeTuple(st.subject, st.relation, st.object, st.expires_at);
3664
+ this.sql.exec(seat.sql, ...seat.params);
3665
+ }
3666
+ // #1659's one exception: the owner-of-record's seat comes back over a revoke when
3667
+ // NOTHING else would let anyone act here — roles projected, no effective role grant.
3668
+ // That is the #332 lockout this path exists to repair, and it is decided by the same
3669
+ // predicate as the flip guard below, so "locked out" means one thing in this unit.
3670
+ // With any other effective holder, the revoke stands: a hand-over that seats a
3671
+ // successor before unseating the owner is not undone by the next promote. A holder of
3672
+ // a role the vertical no longer defines is NOT one — it passes no check, so it must
3673
+ // not stand in for the holder this repair exists to restore.
3674
+ if (roles.length > 0 && !this.hasEffectiveRoleGrant(tenantId)) {
3675
+ for (const st of scopeTuples ?? []) {
3676
+ if (!st.lockout_reseat)
3677
+ continue;
3678
+ this.sql.exec(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at, revoked_at)
3679
+ VALUES (?, ?, ?, ?, NULL)`, st.subject, st.relation, st.object, st.expires_at);
3680
+ }
3402
3681
  }
3403
3682
  // #332: only switch on strict local enforcement when SOMEONE actually holds a role.
3404
- // A projection that leaves role definitions but no live principal→role grant would make
3405
- // every check fail closed — a scope serving nothing but denials, unfixable from inside.
3406
- // Leave `permission_source` as-is instead; a reconcile that restores the owner grant
3407
- // re-runs this and flips safely. (A CP-less vertical uses the local reader regardless of
3408
- // this flag, so the owner grant is written above in the same unit — this guard is the
3409
- // belt to that suspenders, and it protects the CP-backed flip outright.)
3410
- if (roles.length > 0 && !this.hasLiveRoleGrant(tenantId))
3683
+ // A projection that leaves role definitions but no effective principal→role grant would
3684
+ // make every check fail closed — a scope serving nothing but denials, unfixable from
3685
+ // inside. Leave `permission_source` as-is instead; a reconcile that restores the owner
3686
+ // grant re-runs this and flips safely. (A CP-less vertical uses the local reader
3687
+ // regardless of this flag, so the owner grant is written above in the same unit — this
3688
+ // guard is the belt to that suspenders, and it protects the CP-backed flip outright.)
3689
+ if (roles.length > 0 && !this.hasEffectiveRoleGrant(tenantId))
3411
3690
  return;
3412
3691
  this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES ('permission_source', 'local')`);
3413
3692
  });
@@ -3428,23 +3707,17 @@ export function defineScopeDO(modules, bareOps) {
3428
3707
  return undefined;
3429
3708
  return { principal: row.principal_id, scopeId: row.scope_id };
3430
3709
  }
3431
- /** True if any live (non-revoked, unexpired) principal→role grant exists for this tenant,
3432
- * at scope OR tenant level — the precondition for switching on strict local enforcement so a
3433
- * projection never enables fail-closed evaluation against an empty tuple table (#332). */
3434
- hasLiveRoleGrant(tenantId) {
3435
- const now = new Date().toISOString();
3436
- const scope = this.sql
3437
- .exec(`SELECT 1 FROM _substrat_tuples
3438
- WHERE relation LIKE 'role:%' AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)
3439
- LIMIT 1`, now)
3440
- .toArray();
3441
- if (scope.length > 0)
3442
- return true;
3443
- return (this.sql
3444
- .exec(`SELECT 1 FROM _substrat_tenant_tuples
3445
- WHERE tenant_id = ? AND relation LIKE 'role:%' AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)
3446
- LIMIT 1`, tenantId, now)
3447
- .toArray().length > 0);
3710
+ /** True if some principal holds a role this scope can actually EXPAND — a live
3711
+ * (non-revoked, unexpired) `role:<key>` tuple, at scope OR tenant level, whose key names a
3712
+ * current, non-revoked role definition for this tenant. The one predicate behind both the
3713
+ * #332 flip guard and #1659's owner re-seat in `applyProjection`; the query is the
3714
+ * kernel's `effectiveRoleGrantQuery`, where it is tested against a real SQLite. A tuple
3715
+ * for a role the vertical no longer defines counts for nothing, exactly as in the local
3716
+ * checker, which expands a role only through its definition. */
3717
+ hasEffectiveRoleGrant(tenantId) {
3718
+ const q = effectiveRoleGrantQuery(tenantId, new Date().toISOString());
3719
+ const row = this.sql.exec(q.sql, ...q.params).toArray()[0];
3720
+ return row?.effective === 1;
3448
3721
  }
3449
3722
  };
3450
3723
  }