@substrat-run/adapter-cloudflare 0.139.0 → 0.141.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,15 +1,23 @@
1
1
  import { REWIND_REFUSED } from './rewind-refusal.js';
2
2
  import { SYSTEM_DOOR_MOVED } from './system-door.js';
3
3
  import { DurableObject } from 'cloudflare:workers';
4
- import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, entityObjectRef, assertModuleEmittableType, assertKernelAuthoredType, 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, errorCodeOf, assertReplayableDump, REDRAIN_BATCH, } from '@substrat-run/contracts';
5
- import { ulid, DO_SQL_LIMITS, unknownRoleError, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, redactSubjectScopeText, assertRowLimit, assertRowOffset, JOB_RUN_PATCH_SQL, JOB_RUN_CLAIM_SQL, JOB_RUN_RENEW_SQL, JOB_RUN_BEGIN_SQL, JOB_RUN_MISS_SQL, JOB_RUN_MISS_SETTLE_SQL, admissionMissOutcome, JOB_LEASE_EXPIRED_NOTE, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, applyScopeRoleChange, changeScopeRole, revokeScopeRoles, scopeRoleHolders, effectiveRoleGrantQuery, switchRecordedOff, switchSystemSchedules, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, denialListQuery, refusalListQuery, mapRefusalRow, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexPlans, NotListable, listIndexDdl, listIndexPlans, moduleMigrations, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, attachmentRecordOfRow, enqueueAttachmentText, reconcileAttachmentText, queueAttachmentTextBackfill, startAttachmentTextBackfill, recordAttachmentText, searchAttachments, IDEMPOTENCY_DDL, REFUSALS_TABLE_DDL, REFUSALS_INDEX, refusalInsert, refusalOf, markGuardRefusal, REFUSALS_REBUILD, refusalsAdmitGuards, assertIdempotencyKey, assertPermissionKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, emptyImportResult, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, exportedSinceQuery, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, JOB_RUN_DUE_AT, jobRunListLimit, SYSTEM_DOOR_WAIT, ancestorsWithin, } from '@substrat-run/kernel';
6
- import { isUpgradeRequest, readSubscription, LIVE_FANOUT_LIMIT, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, LIVE_WITHIN_HEADER, decodeLiveWithin, } from './live-reads.js';
4
+ import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, entityObjectRef, assertModuleEmittableType, assertKernelAuthoredType, 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, pageOf, countedPageOf, requestFingerprint, substratError, errorCodeOf, assertReplayableDump, REDRAIN_BATCH, } from '@substrat-run/contracts';
5
+ import { ulid, LIVE_CLOSE, LIVE_SOCKETS_PER_PRINCIPAL, DO_SQL_LIMITS, unknownRoleError, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, eraseSubjectFromModules, moduleErasurePlan, redactSubjectScopeText, assertRowLimit, assertRowOffset, JOB_RUN_PATCH_SQL, JOB_RUN_CLAIM_SQL, JOB_RUN_RENEW_SQL, JOB_RUN_BEGIN_SQL, JOB_RUN_MISS_SQL, JOB_RUN_MISS_SETTLE_SQL, admissionMissOutcome, JOB_LEASE_EXPIRED_NOTE, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, delegatedRevokeSql, writeExplicitTupleIn, grantEntityShapeIn, topUpEntityGrantShapes, applyScopeRoleChange, changeScopeRole, revokeScopeRoles, scopeRoleHolders, effectiveRoleGrantQuery, switchRecordedOff, switchSystemSchedules, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, denialListQuery, refusalListQuery, mapRefusalRow, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexPlans, NotListable, listIndexPlans, moduleMigrations, MIGRATION_DIGEST_FENCE_DDL, MIGRATION_DIGEST_MARK_LEGACY, assertJournalDumpCoherent, assertMigrationSql, migrationDivergence, migrationFailedError, migrationSteps, planMigrations, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, attachmentRecordOfRow, enqueueAttachmentText, reconcileAttachmentText, queueAttachmentTextBackfill, startAttachmentTextBackfill, recordAttachmentText, searchAttachments, IDEMPOTENCY_DDL, REFUSALS_TABLE_DDL, REFUSALS_INDEX, refusalInsert, refusalOf, markGuardRefusal, REFUSALS_REBUILD, refusalsAdmitGuards, assertIdempotencyKey, assertPermissionKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, emptyImportResult, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, exportedSinceQuery, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, JOB_RUN_DUE_AT, jobRunListLimit, SYSTEM_DOOR_WAIT, ancestorsWithin, } from '@substrat-run/kernel';
6
+ import { isUpgradeRequest, readSubscription, LIVE_DECIDE_ATTEMPTS, LIVE_FANOUT_LIMIT, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, LIVE_WITHIN_HEADER, LIVE_EXPIRES_HEADER, decodeLiveWithin, liveInstant, } from './live-reads.js';
7
7
  import { replyOf } from './do-reply.js';
8
8
  import { OperationQueue } from './serialization.js';
9
- import { doScopedSql, doBuiltColumnsOf, doRedactionSql, doSpineSql } from './sql.js';
10
- import { actorOf, admitPeer, collectPeers, switchPeer, peerSubjectRef, subjectGrantState, assertNoSecret, CAPABILITY_DDL, COPY_ORIGIN_DDL, ENTITY_STATE_MOVES_DDL, SWITCH_FENCES_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, createCapabilityVerbs, createEntityEdgeVerbs, createEntityStateVerbs, createTrashedReads, searchStateWhere, uncheckedView, addStatePlans, entityStateTriggerDdl, statefulTablesOf, assertEntityStateIntact, stateListIndexNames, exchangeCapability, guardSecrets, mintBecomeCapability, redactSecrets, redactSecretText, moduleLog, asyncInvocationId, asyncLinePass, resolveCapabilitySession, revokeCapabilityAsPlatform, readCapabilityPage, domainEventOf, facetEvents, readDeadLetters, readLifecycleFlow, readOperationSeries, readHistory, readInvocation, readUndrainedOutbox, walkEventCause, walkEventEffects, } from '@substrat-run/kernel';
9
+ import { assertNoWorkerdStorage, doScopedSql, doBuiltColumnsOf, doRedactionSql, doSpineSql } from './sql.js';
10
+ import { actorOf, admitPeer, collectPeers, switchPeer, peerSubjectRef, subjectGrantState, assertNoSecret, CAPABILITY_DDL, COPY_ORIGIN_DDL, ENTITY_STATE_MOVES_DDL, SWITCH_FENCES_DDL, TABLE_OWNERS_DDL, assertMigrationLeavesLedgerAlone, recordOwnershipSteps, runMigrationStatements, splitSqlStatements, executableSqlStatements, blankSqlComments, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, createCapabilityVerbs, createEntityEdgeVerbs, createEntityStateVerbs, createTrashedReads, CONTINUATION_POSITION_CAP, visibleContinuation, searchStateWhere, uncheckedView, addStatePlans, assertNoCallerPurge, isUnreachableParent, PURGE_BATCH, heldPurgePass, lifecycleRefusal, purgeHeldBy, purgeDueOf, purgeOnlyKeysOf, runPurgePass, refuseTrashedTarget, registerTrashTargets, withheldKeysFor, statefulTablesOf, afterMigration, afterRuntimeDdl, derivesAnything, repairDerivedObjects, StateColumnLost, exchangeCapability, guardSecrets, mintBecomeCapability, redactSecrets, redactSecretText, moduleLog, asyncInvocationId, asyncLinePass, resolveCapabilitySession, revokeCapabilityAsPlatform, readCapabilityPage, domainEventOf, facetEvents, readDeadLetters, readLifecycleFlow, readOperationSeries, readHistory, readInvocation, readUndrainedOutbox, walkEventCause, walkEventEffects, } from '@substrat-run/kernel';
11
+ import { COPY_RESTORE_FENCE_LAPSED, copyRestoreFenceLapsed } from '@substrat-run/kernel';
11
12
  import { createDoTupleChecker, createLocalControlPlaneReader, scopeTupleReader } from './checker.js';
12
- import { CARRIED_AWAY_KEY, COPY_MARK_CLEARED_KEY, KEPT_COPY_REFUSAL, KEPT_DIVERGENT_KEY, LOAD_STAMP_KEY, STORE_LOCAL_META_KEYS, WRITE_REVISION_KEY, carriedAwayDump, isCopyMarkInsert, isWriteStatement, assertNoSpineReference, assertSpineTablesBuilt, capabilitiesForLoad, clearCopyMarker, dumpRowsInsert, isSpineTable, markCopyOrigin, repointScopeGrants, settleCopiedWork, emittedHere, IS_COPY_SQL, isCopyLoad, isLifecycleWrite, readLifecycle, settleLifecycleAfterLoad, writeLifecycle, spineColumnAdditions } from '@substrat-run/kernel';
13
+ import { settlePlatformRequestIn } from '@substrat-run/kernel';
14
+ import { CARRIED_AWAY_KEY, COPY_MARK_CLEARED_KEY, KEPT_COPY_REFUSAL, KEPT_DIVERGENT_KEY, LOAD_STAMP_KEY, STORE_LOCAL_META_KEYS, WRITE_REVISION_KEY, carriedAwayDump, isCopyMarkInsert, isWriteStatement, assertSpineTablesBuilt, capabilitiesForLoad, clearCopyMarker, dumpRowsInsert, isSpineTable, markCopyOrigin, repointScopeGrants, settleCopiedWork, emittedHere, IS_COPY_SQL, isCopyLoad, isLifecycleWrite, readLifecycle, settleLifecycleAfterLoad, writeLifecycle, spineColumnAdditions } from '@substrat-run/kernel';
15
+ import { GRANT_CHILDREN_INDEX_DDL, grantedEntitiesForContext } from '@substrat-run/kernel';
16
+ /** A scope's migration journal: `module@version` → the SQL digest it recorded (#2066). */
17
+ function readAppliedMigrations(sql) {
18
+ const rows = sql.exec('SELECT module_id, version, sql_digest FROM _substrat_migrations').toArray();
19
+ return new Map(rows.map((r) => [`${r.module_id}@${r.version}`, r.sql_digest]));
20
+ }
13
21
  /**
14
22
  * The key marking a scope DO whose storage was destroyed (`destroyStorage`).
15
23
  * Written after `deleteAll()` so it survives the wipe, and never cleared: `reaped`
@@ -32,6 +40,9 @@ const foreignTenant = (held, tenantId) => held !== null
32
40
  : `refused: this scope holds role rows for another tenant and none for ${tenantId}`;
33
41
  /** #2016: the load refusals `tenantReceiptRefusal` raised, told apart by identity, not by text. */
34
42
  const tenantRefusals = new WeakSet();
43
+ /** #1722: a load refused because its copy move's lease ran out (`CopyRestoreFence`), told apart
44
+ * from a store that moved, since the host must not read it as an applied retry. */
45
+ const fenceRefusals = new WeakSet();
35
46
  /**
36
47
  * The scope spine, as this adapter builds it — one of two hand-written copies (#969).
37
48
  *
@@ -179,8 +190,13 @@ const KERNEL_DDL = `
179
190
  applied_at TEXT NOT NULL,
180
191
  duration_ms INTEGER,
181
192
  rows_changed INTEGER,
193
+ -- #2066: SHA-256 of the SQL that ran (kernel migrationDigest). NULL on a row written
194
+ -- before the column: unrecorded, accepted, never backfilled.
195
+ sql_digest TEXT,
182
196
  PRIMARY KEY (module_id, version)
183
197
  );
198
+ -- #2066: no new journal row without its digest (the kernel's comment says why).
199
+ ${MIGRATION_DIGEST_FENCE_DDL}
184
200
  -- #286: the PITR bookmark taken immediately BEFORE a migration pass runs on a
185
201
  -- scope that already holds data -- the precise rewind point a backout restores
186
202
  -- to. Rows live in the same storage they describe, so a rewind erases the rows
@@ -192,6 +208,7 @@ const KERNEL_DDL = `
192
208
  pending TEXT NOT NULL
193
209
  );
194
210
  ${SWITCH_FENCES_DDL}
211
+ ${TABLE_OWNERS_DDL}
195
212
  CREATE TABLE IF NOT EXISTS _substrat_tuples (
196
213
  subject TEXT NOT NULL,
197
214
  relation TEXT NOT NULL,
@@ -202,6 +219,7 @@ const KERNEL_DDL = `
202
219
  revoked_at TEXT,
203
220
  PRIMARY KEY (subject, relation, object)
204
221
  );
222
+ ${GRANT_CHILDREN_INDEX_DDL};
205
223
  CREATE TABLE IF NOT EXISTS _substrat_deliveries (
206
224
  event_id TEXT NOT NULL,
207
225
  consumer_module TEXT NOT NULL,
@@ -420,6 +438,17 @@ class WriteRevision {
420
438
  keeping = false;
421
439
  /** A statement's text is almost always a constant, so its answer is remembered (bounded). */
422
440
  writes = new Map();
441
+ /**
442
+ * Every write statement run through this handle since the object woke, in memory (#938, Codex
443
+ * #2077 r4) — counted per STATEMENT, bookkeeping and suspended ones included, where the durable
444
+ * revision above is bumped once per run. A live fan-out pass decides who may hear a frame across
445
+ * awaits; it reads this immediately before deciding and again immediately before sending, with
446
+ * no await between that second read and the send, and decides again when it moved. Per statement
447
+ * because a decision taken between a transaction's first write (its bump) and a later one (the
448
+ * revoke itself) must not read as current. Over-counting (a rolled-back write, a write no check
449
+ * reads) only costs a pass a re-check.
450
+ */
451
+ written = 0;
423
452
  sql;
424
453
  constructor(raw, storage, suspended,
425
454
  /** Why this store takes no write at all right now, or null (a `carried_away` copy, #1722). */
@@ -429,7 +458,10 @@ class WriteRevision {
429
458
  this.suspended = suspended;
430
459
  this.refusal = refusal;
431
460
  const exec = (query, ...bindings) => {
432
- if (this.keeping && this.isWrite(query)) {
461
+ const write = this.isWrite(query);
462
+ if (write)
463
+ this.written++;
464
+ if (this.keeping && write) {
433
465
  // Bookkeeping takes the copy-marker insert and the lifecycle delivery (#1713) and nothing
434
466
  // else: any other write here would be one the revision never saw, which is the hole this
435
467
  // class exists to close.
@@ -438,7 +470,7 @@ class WriteRevision {
438
470
  }
439
471
  return raw.exec(query, ...bindings);
440
472
  }
441
- if (!this.suspended() && this.isWrite(query)) {
473
+ if (write && !this.suspended()) {
442
474
  const refused = this.refusal();
443
475
  if (refused)
444
476
  throw substratError('conflict', refused);
@@ -504,6 +536,10 @@ class WriteRevision {
504
536
  this.covered = false;
505
537
  });
506
538
  }
539
+ /** How many write statements this handle has run (see `written`). */
540
+ get statementsWritten() {
541
+ return this.written;
542
+ }
507
543
  isWrite(query) {
508
544
  let known = this.writes.get(query);
509
545
  if (known === undefined) {
@@ -590,8 +626,6 @@ function cellToJson(v) {
590
626
  * trigger: it passed on better-sqlite3 (whose `exec` takes the whole blob) and
591
627
  * failed every scope on the DO host.
592
628
  */
593
- const IS_CREATE_TRIGGER = /^\s*CREATE\s+(TEMP\s+|TEMPORARY\s+)?TRIGGER\b/i;
594
- const ENDS_WITH_END = /\bEND\s*$/i;
595
629
  /**
596
630
  * The kernel's two-method SQL handle (`SwitchSql`) over a Durable Object's storage — what the
597
631
  * shared switch rules (#1666) and the directory's switch record (#1674) run through. One
@@ -605,67 +639,8 @@ export function switchSqlOver(sql) {
605
639
  },
606
640
  };
607
641
  }
608
- export function splitSqlStatements(sql) {
609
- const out = [];
610
- let cur = '';
611
- const n = sql.length;
612
- let i = 0;
613
- while (i < n) {
614
- const c = sql[i];
615
- const c2 = sql[i + 1];
616
- if (c === '-' && c2 === '-') {
617
- while (i < n && sql[i] !== '\n')
618
- i += 1; // line comment → end of line
619
- continue;
620
- }
621
- if (c === '/' && c2 === '*') {
622
- i += 2;
623
- while (i < n && !(sql[i] === '*' && sql[i + 1] === '/'))
624
- i += 1;
625
- i += 2; // block comment → past the closing */
626
- continue;
627
- }
628
- if (c === "'") {
629
- cur += c;
630
- i += 1;
631
- while (i < n) {
632
- cur += sql[i];
633
- if (sql[i] === "'") {
634
- if (sql[i + 1] === "'") {
635
- cur += sql[i + 1]; // '' is an escaped quote, still inside the string
636
- i += 2;
637
- continue;
638
- }
639
- i += 1;
640
- break;
641
- }
642
- i += 1;
643
- }
644
- continue;
645
- }
646
- if (c === ';') {
647
- // Inside a trigger body, a `;` ends an inner statement, not the CREATE.
648
- // `END` is matched as a bare word at the end of what has accumulated —
649
- // a string literal ending in END reads as `…END'`, so the quote keeps it
650
- // from matching, and the string scanner above has already copied it whole.
651
- if (IS_CREATE_TRIGGER.test(cur) && !ENDS_WITH_END.test(cur)) {
652
- cur += c;
653
- i += 1;
654
- continue;
655
- }
656
- if (cur.trim())
657
- out.push(cur.trim());
658
- cur = '';
659
- i += 1;
660
- continue;
661
- }
662
- cur += c;
663
- i += 1;
664
- }
665
- if (cur.trim())
666
- out.push(cur.trim());
667
- return out;
668
- }
642
+ /** The splitter both adapters run migrations through — the kernel's, re-exported for this package's callers. */
643
+ export { splitSqlStatements };
669
644
  /**
670
645
  * #1834: the pin missed. Thrown only by `assertSystemDoor`, and module-private, so no operation can
671
646
  * throw one: the two RPCs a door call reaches turn it into the `SystemDoorMoved` ANSWER, by class
@@ -675,9 +650,51 @@ class SystemDoorMovedError extends Error {
675
650
  }
676
651
  /** #1834: the brand only `assertSystemDoor` sets, so nothing else can make a `SystemDoorPass`. */
677
652
  const SYSTEM_DOOR_PASSED = Symbol('system door passed');
653
+ /** Close a live socket whose session has ended (#938). */
654
+ function closeSessionEnded(ws) {
655
+ try {
656
+ ws.close(LIVE_CLOSE.revoked, 'the session that opened this subscription has ended');
657
+ }
658
+ catch {
659
+ // Already gone.
660
+ }
661
+ }
678
662
  export function defineScopeDO(modules, bareOps) {
679
663
  return class ScopeDO extends DurableObject {
680
664
  sql;
665
+ continuationOrder = 0;
666
+ /** DO storage KV is private to the adapter; neither module SQL nor dumps can read it. */
667
+ continuationStore(writable) {
668
+ const positionKey = (id, expiresAt) => `continuation:position:${String(expiresAt).padStart(13, '0')}:${id}`;
669
+ return {
670
+ keys: async () => (await this.ctx.storage.get('continuation:keys')) ?? null,
671
+ setKeys: async (keys) => { await this.ctx.storage.put('continuation:keys', keys); },
672
+ position: async (id, expiresAt) => {
673
+ if (writable)
674
+ await this.pruneContinuationPositions();
675
+ return (await this.ctx.storage.get(positionKey(id, expiresAt))) ?? null;
676
+ },
677
+ setPosition: async (id, position) => {
678
+ // Several budget stops can land in one millisecond. Keep insertion
679
+ // order explicit so the cap always evicts the first locator minted.
680
+ this.continuationOrder = Math.max(Date.now() * 1_000, this.continuationOrder + 1);
681
+ await this.ctx.storage.put(positionKey(id, position.expiresAt), {
682
+ ...position, order: this.continuationOrder,
683
+ });
684
+ await this.pruneContinuationPositions();
685
+ },
686
+ };
687
+ }
688
+ async pruneContinuationPositions() {
689
+ const positions = await this.ctx.storage.list({ prefix: 'continuation:position:' });
690
+ const live = [...positions].filter(([, record]) => record.expiresAt > Date.now())
691
+ .sort((a, b) => (a[1].order ?? 0) - (b[1].order ?? 0));
692
+ const evict = new Set(live.slice(0, Math.max(0, live.length - CONTINUATION_POSITION_CAP)).map(([name]) => name));
693
+ for (const [name, record] of positions) {
694
+ if (record.expiresAt <= Date.now() || evict.has(name))
695
+ await this.ctx.storage.delete(name);
696
+ }
697
+ }
681
698
  queue = new OperationQueue();
682
699
  operations = new Map();
683
700
  /**
@@ -688,6 +705,12 @@ export function defineScopeDO(modules, bareOps) {
688
705
  operationInput = new Map();
689
706
  /** #129: name → the entity whose version an `If-Match` is compared against. */
690
707
  operationConcurrency = new Map();
708
+ /** #119: name → the entity it addresses by id — the host's trash refusal reads it. */
709
+ operationTarget = new Map();
710
+ /** #119: module id → the keys its system principal holds only for its purge schedules. */
711
+ purgeOnlyKeys = new Map();
712
+ /** #119: a purge schedule's operation → the module that declared it and the entity it purges. */
713
+ purgeSchedules = new Map();
691
714
  /** #116: the operations that declared `idempotency: false` — refusals, not participants. */
692
715
  operationIdempotencyOptOut = new Set();
693
716
  modules = new Map();
@@ -713,6 +736,8 @@ export function defineScopeDO(modules, bareOps) {
713
736
  listPlans = new Map();
714
737
  /** #119: entity type → its archive/trash plan, from every registered module. */
715
738
  statePlans = new Map();
739
+ /** #2068: each registered module's erasure, in registration order — what `redactSubject` runs. */
740
+ erasurePlans = [];
716
741
  /** entityType → the declared attachment gate (#473): read key + write key (default: read). */
717
742
  attachmentTargets = new Map();
718
743
  /**
@@ -736,7 +761,8 @@ export function defineScopeDO(modules, bareOps) {
736
761
  * underneath rows it already stored.
737
762
  */
738
763
  mintEventId = createUlid();
739
- applied = new Set();
764
+ /** `module@version` → the SQL digest its journal row recorded, null for a row from before #2066. */
765
+ applied = new Map();
740
766
  migrationPromise;
741
767
  /** Latch: the applied count is reported to the directory once per DO instance. */
742
768
  schemaVersionReported = false;
@@ -779,7 +805,7 @@ export function defineScopeDO(modules, bareOps) {
779
805
  // transaction on `ctx.storage` itself (`carried-copy.test.ts` in the kernel holds that).
780
806
  this.revision = new WriteRevision(ctx.storage.sql, ctx.storage, () => this.revisionSuspended, () => (this.carriedAwayCopy ? CARRIED_AWAY_WRITE_REFUSAL : null));
781
807
  this.sql = this.revision.sql;
782
- for (const stmt of splitSqlStatements(KERNEL_DDL)) {
808
+ for (const stmt of executableSqlStatements(KERNEL_DDL)) {
783
809
  this.sql.exec(stmt);
784
810
  }
785
811
  this.applySpineColumnAdditions();
@@ -790,11 +816,7 @@ export function defineScopeDO(modules, bareOps) {
790
816
  for (const [name, handler] of Object.entries(bareOps))
791
817
  this.defineOperation(name, handler);
792
818
  // Which migrations have already run (a warm DO wakes with rows here).
793
- for (const row of this.sql
794
- .exec('SELECT module_id, version FROM _substrat_migrations')
795
- .toArray()) {
796
- this.applied.add(`${row.module_id}@${row.version}`);
797
- }
819
+ this.applied = readAppliedMigrations(this.sql);
798
820
  // #1335: and where this DO's event ids have to resume from. A revived DO would
799
821
  // otherwise start its floor at the wall clock, and a clock that has stepped back
800
822
  // — an NTP correction is small but real — mints underneath rows already stored,
@@ -810,6 +832,15 @@ export function defineScopeDO(modules, bareOps) {
810
832
  // -- module registration (port of SqliteScopeHost.registerModule) ---------
811
833
  registerModule(registration) {
812
834
  const manifest = registration.manifest;
835
+ // #119: refused before anything is recorded, as in the pure adapter.
836
+ const trashTargets = registerTrashTargets(manifest.id, new Set(Object.keys(registration.operations ?? {})), registration.operationInputs, manifest.entityStates, manifest.schedules);
837
+ this.purgeOnlyKeys.set(manifest.id, purgeOnlyKeysOf(manifest.schedules ?? []));
838
+ for (const schedule of manifest.schedules ?? []) {
839
+ if (schedule.purge)
840
+ this.purgeSchedules.set(schedule.operation, { moduleId: manifest.id, entityType: schedule.purge.entityType });
841
+ }
842
+ // #2068: refused before anything is recorded, as on the pure host.
843
+ const erasure = moduleErasurePlan(registration);
813
844
  if (manifest.peers?.length)
814
845
  this.peerSources.push({ peers: manifest.peers });
815
846
  // #827: the FTS indexes `searchables` declares, appended after the module's
@@ -841,11 +872,14 @@ export function defineScopeDO(modules, bareOps) {
841
872
  id: manifest.id,
842
873
  // The order the kernel writes once (#1677): authored, then search, then list indexes.
843
874
  migrations: moduleMigrations(registration),
875
+ steps: migrationSteps(registration),
844
876
  consumers: Object.entries(registration.consumers ?? {}).map(([eventType, handler]) => ({
845
877
  eventType,
846
878
  handler,
847
879
  })),
848
880
  });
881
+ if (erasure)
882
+ this.erasurePlans.push(erasure);
849
883
  for (const [name, handler] of Object.entries(registration.predicates ?? {})) {
850
884
  this.predicates.set(name, { module: manifest.id, handler });
851
885
  }
@@ -932,6 +966,9 @@ export function defineScopeDO(modules, bareOps) {
932
966
  if (declaredOptOuts.includes(name) && this.operations.has(name)) {
933
967
  this.operationIdempotencyOptOut.add(name);
934
968
  }
969
+ const target = trashTargets.get(name);
970
+ if (target && this.operations.has(name))
971
+ this.operationTarget.set(name, target);
935
972
  }
936
973
  }
937
974
  defineOperation(name, handler) {
@@ -1571,10 +1608,10 @@ export function defineScopeDO(modules, bareOps) {
1571
1608
  * Provisioning does not come through here; it seats with `seatTuple` (#1659).
1572
1609
  */
1573
1610
  async writeTuple(subject, relation, object, expiresAt) {
1574
- await this.queue.enqueue(() => {
1575
- this.sql.exec(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at)
1576
- VALUES (?, ?, ?, ?)`, subject, relation, object, expiresAt);
1577
- });
1611
+ return replyOf(() => this.queue.enqueue(() => {
1612
+ writeExplicitTupleIn(this.switchSql(), subject, relation, object, { kind: 'replace', expiresAt });
1613
+ return null;
1614
+ }));
1578
1615
  }
1579
1616
  /**
1580
1617
  * Provisioning's scope-tuple write (#1659): create the row if it is missing, follow the
@@ -1604,6 +1641,26 @@ export function defineScopeDO(modules, bareOps) {
1604
1641
  return switchOff ? switchRecordedOff(this.switchSql(), switchOff) : [];
1605
1642
  }));
1606
1643
  }
1644
+ /** A declared entity-grant shape's grant to one person on one entity (#2071), as ONE unit. */
1645
+ async grantEntityShape(principal, entity, permissions) {
1646
+ await this.queue.enqueue(() => this.revision.transactionSync(() => grantEntityShapeIn(this.switchSql(), principal, entity, permissions)));
1647
+ }
1648
+ /**
1649
+ * One bounded pass of a declared shape's reconcile (#2071), with its events, in ONE
1650
+ * transaction: how many it topped up, how many it took retired keys from (#2082), and
1651
+ * whether the scope is done.
1652
+ */
1653
+ async topUpEntityGrantShapes(tenantId, scopeId, shapes, limit) {
1654
+ return this.queue.enqueue(() => this.revision.transactionSync(() => topUpEntityGrantShapes(this.switchSql(), {
1655
+ tenantId,
1656
+ scopeId,
1657
+ shapes,
1658
+ now: new Date().toISOString(),
1659
+ limit,
1660
+ mintEventId: (ms) => this.mintEventId(ms),
1661
+ version: this.env.SUBSTRAT_VERSION_ID ?? null,
1662
+ })));
1663
+ }
1607
1664
  /**
1608
1665
  * The dispatch capability's admission (#726 remedy B): this delivery may read the
1609
1666
  * attachments of the entity its own spine row names, and no others.
@@ -1733,7 +1790,7 @@ export function defineScopeDO(modules, bareOps) {
1733
1790
  // Legacy path, byte-for-byte what it was: rewrapped so a non-plain error (a
1734
1791
  // ZodError, whose `message` is a getter) still arrives with its message.
1735
1792
  try {
1736
- return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession, verticalCaller, systemDoorInstance);
1793
+ return await this.#invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession, verticalCaller, systemDoorInstance);
1737
1794
  }
1738
1795
  catch (err) {
1739
1796
  if (err instanceof SystemDoorMovedError)
@@ -1742,7 +1799,7 @@ export function defineScopeDO(modules, bareOps) {
1742
1799
  }
1743
1800
  }
1744
1801
  try {
1745
- return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession, verticalCaller, systemDoorInstance);
1802
+ return await this.#invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession, verticalCaller, systemDoorInstance);
1746
1803
  }
1747
1804
  catch (err) {
1748
1805
  // #1834: the pin missed — an answer, never a failure an operation could have produced.
@@ -1754,8 +1811,14 @@ export function defineScopeDO(modules, bareOps) {
1754
1811
  return { result: undefined, platformRequests: 0, failure: toWireFailure(err) };
1755
1812
  }
1756
1813
  }
1757
- /** The operation path itself. Throws; `invoke` decides how that reaches the caller. */
1758
- async invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions,
1814
+ /**
1815
+ * The operation path itself. Throws; `invoke` decides how that reaches the caller.
1816
+ *
1817
+ * `#`-private, not merely `private`: workerd's RPC exposes every method on the prototype,
1818
+ * and this one takes `purge` (#119), which only `runPurgeSweep` may set. A `#` method is not
1819
+ * on the prototype, so no stub can reach it.
1820
+ */
1821
+ async #invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions,
1759
1822
  /** K-42: the session, resolved coordinator-side. See `invoke` above. */
1760
1823
  impersonation,
1761
1824
  /** #1672: a capability session's hash. See `invoke` above. */
@@ -1763,7 +1826,9 @@ export function defineScopeDO(modules, bareOps) {
1763
1826
  /** #1706: the calling vertical, as the platform named it. See `invoke` above. */
1764
1827
  verticalCaller,
1765
1828
  /** #1834: the instance the system door's gate read. See `invoke` above. */
1766
- systemDoorInstance) {
1829
+ systemDoorInstance,
1830
+ /** #119: set only by `runPurgeSweep` on its own invokes — never an RPC argument. */
1831
+ purge) {
1767
1832
  await this.ensureMigrations();
1768
1833
  const handler = this.operations.get(operation);
1769
1834
  // `not_found`, not a bare throw (#113): every vertical hand-matched this message
@@ -1798,6 +1863,8 @@ export function defineScopeDO(modules, bareOps) {
1798
1863
  // declaration. Outside the queue and outside the transaction: a malformed
1799
1864
  // call takes no turn and opens nothing. Guards read the parsed input too,
1800
1865
  // so a K-17 pre-condition sees what the handler will.
1866
+ // #119: purge authority is never an option a caller supplies.
1867
+ assertNoCallerPurge(invokeOptions);
1801
1868
  const declaredInput = this.operationInput.get(operation);
1802
1869
  const parsed = declaredInput ? declaredInput.parse(input) : input;
1803
1870
  // #129. Refused rather than ignored, for the reason the coordinator refuses an
@@ -1924,8 +1991,16 @@ export function defineScopeDO(modules, bareOps) {
1924
1991
  // commit together, or a throw (from either) rolls domain writes AND
1925
1992
  // emitted events back as one — verified across `await` in workerd.
1926
1993
  try {
1994
+ // #119: a module's purge-only keys are withheld from its system principal on every
1995
+ // call but the purge sweep's own invoke of the purge operation.
1996
+ const target = this.operationTarget.get(operation);
1997
+ // #119: purge authority is the system door's, and only `runPurgeSweep` sets `purge`.
1998
+ const purging = purge === true && systemDoor !== undefined;
1999
+ const withheld = systemDoor
2000
+ ? withheldKeysFor(this.purgeOnlyKeys.get(systemDoor.moduleId), target, purging)
2001
+ : undefined;
1927
2002
  await this.revision.transaction(async () => {
1928
- const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemDoor, signals, impersonation, operation, capabilityId, minted, undefined, peerSubject);
2003
+ const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemDoor, signals, impersonation, operation, capabilityId, minted, undefined, peerSubject, undefined, withheld);
1929
2004
  // #116: a retry is answered from the recording, and nothing else runs
1930
2005
  // — not the guards, not the handler, not the permission check inside
1931
2006
  // it. Keyed by SUBJECT, so a caller only ever reaches its own
@@ -1952,6 +2027,9 @@ export function defineScopeDO(modules, bareOps) {
1952
2027
  const seen = guardedRef && invokeOptions?.ifMatch !== undefined
1953
2028
  ? this.versionAt(guardedRef)
1954
2029
  : undefined;
2030
+ // #119: the host's trash refusal, before the guards and the handler — the pure
2031
+ // adapter's, through the same kernel function.
2032
+ await refuseTrashedTarget({ sql: doSpineSql(this.sql), plans: this.statePlans, check: ctx.check }, operation, target, parsed, purging ? { now: new Date().toISOString(), gate: this.purgeGateFacts(systemDoor.moduleId, tenantId) } : undefined);
1955
2033
  await this.runGuards(operation, ctx, parsed);
1956
2034
  result = await handler(ctx, parsed);
1957
2035
  if (guardedRef && invokeOptions?.ifMatch !== undefined) {
@@ -2148,12 +2226,57 @@ export function defineScopeDO(modules, bareOps) {
2148
2226
  if (withinHeader !== null && !within) {
2149
2227
  return new Response('live reads: unreadable within narrowing', { status: 500 });
2150
2228
  }
2229
+ // The same for an expiry: dropping it would keep the socket open past its session.
2230
+ const expiresHeader = request.headers.get(LIVE_EXPIRES_HEADER);
2231
+ const expiresAt = expiresHeader === null ? undefined : liveInstant(expiresHeader);
2232
+ if (expiresHeader !== null && !expiresAt) {
2233
+ return new Response('live reads: unreadable expiry', { status: 500 });
2234
+ }
2151
2235
  // A subscriber arriving before the scope's migrations have run would be told
2152
2236
  // about events against a schema it cannot read back through. Same gate every
2153
2237
  // other entry point takes, for the same reason.
2154
2238
  await this.ensureMigrations();
2239
+ // `.parse`, not a cast: these three arrived as header strings, and the branded
2240
+ // ids are what every check downstream is keyed on. A malformed one would
2241
+ // otherwise be carried all the way to a `ctx.check` that quietly matches nothing —
2242
+ // which reads as "this subscriber may see nothing" and is indistinguishable from
2243
+ // a correct denial. Refused here instead, where it is still one subscriber's
2244
+ // problem. Throwing is right: the coordinator built this request.
2245
+ const subscriber = {
2246
+ principal: principalId.parse(principal),
2247
+ tenantId: tenantIdOf.parse(tenantId),
2248
+ scopeId: scopeIdOf.parse(scopeId),
2249
+ };
2250
+ // A checked root (#938) is gated here as well as on every pass: a subscriber who may
2251
+ // not watch it gets no socket at all, so its client meets a refusal rather than a
2252
+ // feed that would close on the first thing it had to say. A session already over is
2253
+ // refused the same way.
2254
+ const expired = expiresAt !== undefined && expiresAt <= new Date().toISOString();
2255
+ if (expired ||
2256
+ (within?.checked !== undefined && !(await this.mayWatchRoot(() => this.liveContext(subscriber), within)))) {
2257
+ return new Response('live reads: the subscriber may not watch this root', {
2258
+ status: 403,
2259
+ headers: { [LIVE_MODE_HEADER]: 'forbidden' },
2260
+ });
2261
+ }
2155
2262
  const pair = new WebSocketPair();
2156
2263
  const [client, server] = Object.values(pair);
2264
+ /**
2265
+ * At most `LIVE_SOCKETS_PER_PRINCIPAL` sockets per principal on this scope (#938):
2266
+ * each is work on every post-commit pass. The extra one is accepted and closed at
2267
+ * once with `4429`, rather than refused with a status, because a browser never sees
2268
+ * a failed handshake's status — only a close code, which the client reads as "poll
2269
+ * and stop asking". Not hibernated, so it never joins the roster the fan-out walks.
2270
+ *
2271
+ * Counted over the sockets still live: one whose session has ended is closed here
2272
+ * first, so sockets that expired on an idle scope never hold a fresh session out.
2273
+ */
2274
+ const held = this.reapExpiredLive().filter((s) => s.principal === subscriber.principal).length;
2275
+ if (held >= LIVE_SOCKETS_PER_PRINCIPAL) {
2276
+ server.accept();
2277
+ server.close(LIVE_CLOSE.tooMany, 'too many live subscriptions for this principal; poll instead');
2278
+ return new Response(null, { status: 101, webSocket: client });
2279
+ }
2157
2280
  // HIBERNATABLE, not `server.accept()`. A scope with a watcher open would
2158
2281
  // otherwise be pinned in memory for as long as somebody has a tab open, which is
2159
2282
  // the cost model inverted: a support desk being WATCHED is the normal state.
@@ -2161,21 +2284,94 @@ export function defineScopeDO(modules, bareOps) {
2161
2284
  // eviction, so the socket would stay open and silently stop receiving, which is
2162
2285
  // indistinguishable from a quiet scope.
2163
2286
  this.ctx.acceptWebSocket(server);
2164
- // `.parse`, not a cast: these three arrived as header strings, and the branded
2165
- // ids are what every check downstream is keyed on. A malformed one would
2166
- // otherwise be carried all the way to a `ctx.check` that quietly matches nothing —
2167
- // which reads as "this subscriber may see nothing" and is indistinguishable from
2168
- // a correct denial. Refused here instead, where it is still one subscriber's
2169
- // problem. Throwing is right: the coordinator built this request.
2170
2287
  server.serializeAttachment({
2171
- principal: principalId.parse(principal),
2172
- tenantId: tenantIdOf.parse(tenantId),
2173
- scopeId: scopeIdOf.parse(scopeId),
2288
+ ...subscriber,
2174
2289
  since: new Date().toISOString(),
2175
2290
  ...(within ? { within } : {}),
2291
+ ...(expiresAt ? { expiresAt } : {}),
2176
2292
  });
2293
+ if (expiresAt)
2294
+ await this.armLiveExpiry();
2177
2295
  return new Response(null, { status: 101, webSocket: client });
2178
2296
  }
2297
+ /**
2298
+ * Close every live socket whose session has ended (`1008`), and return the
2299
+ * subscriptions still open (#938). Reads only the sockets' own attachments, never the
2300
+ * database, so it is as cheap on a scope nobody writes to as on a busy one.
2301
+ */
2302
+ reapExpiredLive(now = new Date().toISOString()) {
2303
+ const live = [];
2304
+ for (const ws of this.ctx.getWebSockets()) {
2305
+ let s = null;
2306
+ try {
2307
+ s = readSubscription(ws.deserializeAttachment());
2308
+ }
2309
+ catch {
2310
+ s = null;
2311
+ }
2312
+ if (!s)
2313
+ continue;
2314
+ if (s.expiresAt !== undefined && s.expiresAt <= now) {
2315
+ closeSessionEnded(ws);
2316
+ continue;
2317
+ }
2318
+ live.push(s);
2319
+ }
2320
+ return live;
2321
+ }
2322
+ /**
2323
+ * Arm the scope's alarm for the earliest session end among its live sockets (#938), so
2324
+ * an expired socket is closed on a scope nobody writes to — the fan-out closes them
2325
+ * too, but only on a pass, and an idle scope has none.
2326
+ *
2327
+ * Only ever moved EARLIER: an alarm already set before that instant is kept, and the
2328
+ * handler re-arms for whatever is next. Re-arming on a close is not needed: an alarm
2329
+ * that finds the socket already gone simply arms for the next one, or for nothing.
2330
+ *
2331
+ * The live reaper is this DO's only alarm use. A second one must share `alarm()` and
2332
+ * this "earliest wins" rule, not call `setAlarm` on its own. The alarm makes workerd
2333
+ * keep a `_cf_METADATA` table in the scope's SQLite; every table walk here skips
2334
+ * `_cf_*` for that reason (`exportDump`, `importDump`, `introspectTables`).
2335
+ */
2336
+ async armLiveExpiry() {
2337
+ let earliest;
2338
+ for (const s of this.reapExpiredLive()) {
2339
+ if (s.expiresAt !== undefined && (earliest === undefined || s.expiresAt < earliest))
2340
+ earliest = s.expiresAt;
2341
+ }
2342
+ if (earliest === undefined)
2343
+ return;
2344
+ const at = Date.parse(earliest);
2345
+ const current = await this.ctx.storage.getAlarm();
2346
+ if (current === null || current > at)
2347
+ await this.ctx.storage.setAlarm(at);
2348
+ }
2349
+ /** The live reaper's alarm (#938): close what has expired, then arm for what is next. */
2350
+ async alarm() {
2351
+ await this.armLiveExpiry();
2352
+ }
2353
+ /** The context a live subscriber's checks run in: its own principal, under `live.subscribe`. */
2354
+ liveContext(subscriber) {
2355
+ return this.operationContext(subscriber.principal, subscriber.tenantId, subscriber.scopeId, undefined, undefined, undefined, undefined, undefined, 'live.subscribe');
2356
+ }
2357
+ /**
2358
+ * May this subscriber watch its `checkedWithin` root (#938)? Its own `ctx.check` of the
2359
+ * stated key on the root, the same walk a read of the root would make. A check that
2360
+ * throws is a refusal: an outage in the permission path must not become a feed.
2361
+ */
2362
+ async mayWatchRoot(context, within) {
2363
+ try {
2364
+ // Built inside the try: a context that cannot be built is a refusal too.
2365
+ const decision = await context().check(within.checked, {
2366
+ entityType: within.entityType,
2367
+ entityId: within.entityId,
2368
+ });
2369
+ return decision.allowed;
2370
+ }
2371
+ catch {
2372
+ return false;
2373
+ }
2374
+ }
2179
2375
  /**
2180
2376
  * A subscriber said something.
2181
2377
  *
@@ -2195,6 +2391,9 @@ export function defineScopeDO(modules, bareOps) {
2195
2391
  */
2196
2392
  webSocketMessage(ws, message) {
2197
2393
  this.webSocketMessagesHandled++;
2394
+ // Ungated, and safe to be: the reply is the constant `'pong'` and carries no event, no
2395
+ // entity and no time, so it tells the socket nothing it did not already know. Every
2396
+ // frame that is about the scope's data goes out through `fanOutLive`'s gates instead.
2198
2397
  if (message === 'ping')
2199
2398
  ws.send('pong');
2200
2399
  }
@@ -2328,12 +2527,33 @@ export function defineScopeDO(modules, bareOps) {
2328
2527
  * operation has already moved the edge the walk follows.
2329
2528
  */
2330
2529
  const parents = scopeTupleReader(this.sql);
2331
- const now = new Date().toISOString();
2530
+ /**
2531
+ * A checked root's gate, asked once per (principal, key, root) per pass (#938): a
2532
+ * principal holding that root in several tabs is one check, fanned out to each socket.
2533
+ */
2534
+ const rootChecks = new Map();
2332
2535
  const ancestors = new Map();
2333
- const reaches = async (row, root) => {
2536
+ const openEpoch = (now) => ({
2537
+ writes: this.revision.statementsWritten,
2538
+ until: this.authorityLapsesAfter(now),
2539
+ remote: this.permissionSourceIsRemote(),
2540
+ });
2541
+ const holds = (e, now) => e.writes === this.revision.statementsWritten && (e.until === null || now < e.until);
2542
+ let epoch = openEpoch(new Date().toISOString());
2543
+ /** The epoch current at `now`, opening a new one (and forgetting every memo) if not. */
2544
+ const epochAt = (now) => {
2545
+ if (!holds(epoch, now)) {
2546
+ epoch = openEpoch(now);
2547
+ rootChecks.clear();
2548
+ ancestors.clear();
2549
+ }
2550
+ return epoch;
2551
+ };
2552
+ const reaches = async (row, root, now) => {
2334
2553
  let up = ancestors.get(row.id);
2335
2554
  if (!up) {
2336
2555
  // A walk that cannot answer is a frame not sent — the same fail-closed rule as the check.
2556
+ // Walked at the decision's own instant; remembered only while its epoch holds.
2337
2557
  up = ancestorsWithin(parents, { entityType: row.entity_type, entityId: row.entity_id }, now).catch(() => new Set());
2338
2558
  ancestors.set(row.id, up);
2339
2559
  }
@@ -2357,6 +2577,18 @@ export function defineScopeDO(modules, bareOps) {
2357
2577
  // assumption about how the subscription was created.
2358
2578
  if (subscription.tenantId !== tenantId || subscription.scopeId !== scopeId)
2359
2579
  continue;
2580
+ // The session that opened it has ended (#938): closed before anything is sent,
2581
+ // whatever the subscriber's grants still say. Checked on every pass, so it holds for
2582
+ // every kind of subscription, not only the ones with a gate to ask — and asked
2583
+ // again, against a fresh clock, immediately before every send below, because the
2584
+ // walk and the checks between here and there await, and a session can end during
2585
+ // them.
2586
+ const { expiresAt } = subscription;
2587
+ const sessionEnded = () => expiresAt !== undefined && expiresAt <= new Date().toISOString();
2588
+ if (sessionEnded()) {
2589
+ closeSessionEnded(ws);
2590
+ continue;
2591
+ }
2360
2592
  const { within } = subscription;
2361
2593
  // One context per subscriber, not per event: `ctx.check` is the expensive part
2362
2594
  // and the context is only the subject it is evaluated for. Built on first use, so
@@ -2372,57 +2604,115 @@ export function defineScopeDO(modules, bareOps) {
2372
2604
  // The operation name is carried anyway, for the events a fan-out cannot emit
2373
2605
  // but a future reader of this context might.
2374
2606
  let ctx;
2375
- const { principal } = subscription;
2376
- const context = () => (ctx ??= this.operationContext(principal, tenantId, scopeId, undefined, undefined, undefined, undefined, undefined, 'live.subscribe'));
2377
- for (const row of announceable) {
2607
+ const subscriber = subscription;
2608
+ const context = () => (ctx ??= this.liveContext(subscriber));
2609
+ /**
2610
+ * A checked root's gate (#938), asked at most once per (principal, key, root) per
2611
+ * pass (`rootChecks`) and only once a row beneath the root is about to be announced,
2612
+ * so an idle subscription costs no check. A refusal, or a check that throws, closes the socket before anything is
2613
+ * sent: the grant is gone, the principal is, or the root moved out of the grant's
2614
+ * reach. Closed rather than skipped, so the subscription does not sit there asking
2615
+ * on every pass, and the client's reconnect meets the handshake's 403. Held with the
2616
+ * write count it was decided under, and asked again once that has moved.
2617
+ */
2618
+ let rootVerdict;
2619
+ /** What this subscriber is owed for one row, decided at `now` under epoch `e`. */
2620
+ const decide = async (row, e, now) => {
2621
+ // A remote authority's verdict is never reused: see `Epoch`.
2622
+ if (rootVerdict?.epoch !== e || e.remote)
2623
+ rootVerdict = undefined;
2378
2624
  // Narrowing first: it is memoised across sockets, and a row outside the root
2379
2625
  // is out whatever the principal holds.
2380
- if (within && !(await reaches(row, within)))
2381
- continue;
2382
- let frame;
2383
- if (within?.vouched !== undefined) {
2384
- // The vertical vouched for the root, so the walk above was the whole filter
2385
- // — and the subscriber holds no read on this row, so it is told only that
2626
+ if (within && !(await reaches(row, within, now)))
2627
+ return 'skip';
2628
+ if (within?.checked !== undefined) {
2629
+ if (rootVerdict === undefined) {
2630
+ let check;
2631
+ if (e.remote) {
2632
+ check = this.mayWatchRoot(context, within);
2633
+ }
2634
+ else {
2635
+ const key = `${subscriber.principal}\n${within.checked}\n${within.entityType}:${within.entityId}`;
2636
+ check = rootChecks.get(key);
2637
+ if (!check)
2638
+ rootChecks.set(key, (check = this.mayWatchRoot(context, within)));
2639
+ }
2640
+ rootVerdict = { epoch: e, allowed: await check };
2641
+ }
2642
+ if (!rootVerdict.allowed)
2643
+ return 'revoked';
2644
+ }
2645
+ if (within?.vouched !== undefined || within?.checked !== undefined) {
2646
+ // The root was vouched for or checked, so the walk above was the row filter —
2647
+ // and the subscriber holds no read on this row, so it is told only that
2386
2648
  // something beneath its root changed. Never which row, never how.
2387
- frame = { kind: 'nudge', id: row.id, at: row.occurred_at };
2649
+ return { kind: 'nudge', id: row.id, at: row.occurred_at };
2388
2650
  }
2389
- else {
2390
- // Non-null: `announceable` is exactly the rows whose type is in the map.
2391
- const permission = this.liveTargets.get(row.entity_type);
2392
- let allowed = false;
2393
- try {
2394
- const decision = await context().check(permission, {
2395
- entityType: row.entity_type,
2396
- entityId: row.entity_id,
2397
- });
2398
- allowed = decision.allowed;
2399
- }
2400
- catch {
2401
- // A check that cannot answer is a check that refuses. The alternative —
2402
- // treating an evaluator failure as an allow — turns an outage in the
2403
- // permission path into a disclosure, which is the one failure mode this
2404
- // surface must not have.
2405
- allowed = false;
2406
- }
2407
- if (!allowed)
2408
- continue;
2409
- frame = {
2410
- kind: 'change',
2411
- id: row.id,
2412
- type: row.type,
2651
+ // Non-null: `announceable` is exactly the rows whose type is in the map.
2652
+ const permission = this.liveTargets.get(row.entity_type);
2653
+ try {
2654
+ const decision = await context().check(permission, {
2413
2655
  entityType: row.entity_type,
2414
2656
  entityId: row.entity_id,
2415
- at: row.occurred_at,
2416
- };
2417
- }
2418
- try {
2419
- ws.send(JSON.stringify(frame));
2657
+ });
2658
+ if (!decision.allowed)
2659
+ return 'skip';
2420
2660
  }
2421
2661
  catch {
2422
- // The socket went away between `getWebSockets()` and here. Stop writing to
2423
- // this one and move on; the runtime will deliver `webSocketClose`.
2424
- break;
2662
+ // A check that cannot answer is a check that refuses. The alternative —
2663
+ // treating an evaluator failure as an allow — turns an outage in the
2664
+ // permission path into a disclosure, which is the one failure mode this
2665
+ // surface must not have.
2666
+ return 'skip';
2667
+ }
2668
+ return {
2669
+ kind: 'change',
2670
+ id: row.id,
2671
+ type: row.type,
2672
+ entityType: row.entity_type,
2673
+ entityId: row.entity_id,
2674
+ at: row.occurred_at,
2675
+ };
2676
+ };
2677
+ rows: for (const row of announceable) {
2678
+ for (let attempt = 0; attempt < LIVE_DECIDE_ATTEMPTS; attempt++) {
2679
+ const decidedAt = new Date().toISOString();
2680
+ const e = epochAt(decidedAt);
2681
+ const verdict = await decide(row, e, decidedAt);
2682
+ // Every gate as close to the send as it can be, with no await between these
2683
+ // reads and the send below — so nothing can land after them and before it.
2684
+ // The store wrote, or something the decision relied on lapsed, while it was being
2685
+ // taken (Codex #2077 r4, r5): decide again, from nothing remembered.
2686
+ if (!holds(e, new Date().toISOString()))
2687
+ continue;
2688
+ if (verdict === 'skip')
2689
+ continue rows;
2690
+ if (verdict === 'revoked') {
2691
+ try {
2692
+ ws.close(LIVE_CLOSE.revoked, 'the subscriber may no longer watch this root');
2693
+ }
2694
+ catch {
2695
+ // Already gone.
2696
+ }
2697
+ break rows;
2698
+ }
2699
+ // The clock is read again here too, after every await above.
2700
+ if (sessionEnded()) {
2701
+ closeSessionEnded(ws);
2702
+ break rows;
2703
+ }
2704
+ try {
2705
+ ws.send(JSON.stringify(verdict));
2706
+ }
2707
+ catch {
2708
+ // The socket went away between `getWebSockets()` and here. Stop writing to
2709
+ // this one and move on; the runtime will deliver `webSocketClose`.
2710
+ break rows;
2711
+ }
2712
+ continue rows;
2425
2713
  }
2714
+ // Still moving after every attempt: this row is not announced, and the client's
2715
+ // poll — the floor under every push — picks it up.
2426
2716
  }
2427
2717
  }
2428
2718
  }
@@ -2696,7 +2986,7 @@ export function defineScopeDO(modules, bareOps) {
2696
2986
  return this.queue.enqueue(async () => {
2697
2987
  let capability;
2698
2988
  try {
2699
- capability = resolveCapabilitySession(doSpineSql(this.sql), sessionHash, instant.parse(new Date().toISOString()), operation);
2989
+ capability = resolveCapabilitySession(doSpineSql(this.sql), sessionHash, instant.parse(new Date().toISOString()), operation, true);
2700
2990
  }
2701
2991
  catch (err) {
2702
2992
  return { failure: toWireFailure(err) };
@@ -2858,8 +3148,7 @@ export function defineScopeDO(modules, bareOps) {
2858
3148
  return this.queue.enqueue(() => {
2859
3149
  if (systemSwitchedOff(this.switchSql(), moduleId))
2860
3150
  return false;
2861
- this.sql.exec(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at)
2862
- VALUES (?, ?, ?, ?)`, `system:${moduleId}`, relation, object, expiresAt);
3151
+ writeExplicitTupleIn(this.switchSql(), `system:${moduleId}`, relation, object, { kind: 'replace', expiresAt });
2863
3152
  return true;
2864
3153
  });
2865
3154
  }
@@ -3248,6 +3537,70 @@ export function defineScopeDO(modules, bareOps) {
3248
3537
  await this.ensureMigrations();
3249
3538
  return readCapabilityPage(doSpineSql(this.sql), filter);
3250
3539
  }
3540
+ /**
3541
+ * One pass of a purge horizon's schedule on this scope (#119): the oldest entities due for
3542
+ * purge, each deleted by the module's own `trashed: 'purges'` operation, one call and one
3543
+ * transaction per entity, as `system:<moduleId>`.
3544
+ *
3545
+ * The caller names only WHICH schedule. Everything that decides what is purged is this
3546
+ * object's: the module and the entity come from the registered schedule, the cutoff from this
3547
+ * object's clock and the entity's declared horizon, the ids from its own bin — and each purge
3548
+ * re-checks the cutoff inside its transaction (`refuseTrashedTarget`). Purge authority lives
3549
+ * here and nowhere else: `invoke` takes no purge argument, so a stub holding this namespace
3550
+ * cannot hand the system principal its purge-only key, and calling this early, often or from
3551
+ * outside the sweep can only purge what is genuinely due.
3552
+ *
3553
+ * Pinned like every system-door call (#1834): a missed pin answers `SystemDoorMoved` before
3554
+ * anything is read, and the coordinator's door gates again.
3555
+ */
3556
+ async runPurgeSweep(operation, tenantId,
3557
+ /**
3558
+ * The node the purges' checks and events are stamped with. This object records no scope id of
3559
+ * its own, so it cannot hold this one to anything — and it routes nothing: the sweep runs on
3560
+ * the scope that received the call, whatever this says. A wrong one fails closed, since the
3561
+ * module's grant is seated on this scope's node and not on the one named.
3562
+ */
3563
+ scopeId, systemDoorInstance) {
3564
+ await this.ensureMigrations();
3565
+ const schedule = this.purgeSchedules.get(operation);
3566
+ if (!schedule)
3567
+ throw toRpcError(substratError('not_found', `no purge schedule runs ${operation} on this scope`));
3568
+ try {
3569
+ this.assertSystemDoor(schedule.moduleId, systemDoorInstance);
3570
+ }
3571
+ catch (err) {
3572
+ if (err instanceof SystemDoorMovedError)
3573
+ return SYSTEM_DOOR_MOVED;
3574
+ throw toRpcError(err);
3575
+ }
3576
+ await this.pruneContinuationPositions();
3577
+ // #119: the gate the coordinator applies before any schedule fires, applied here from what
3578
+ // this scope records of it (`purgeGateFacts`). A foreign tenant throws.
3579
+ const held = purgeHeldBy(this.purgeGateFacts(schedule.moduleId, tenantId));
3580
+ if (held !== null)
3581
+ return heldPurgePass(held);
3582
+ const due = purgeDueOf(doSpineSql(this.sql), this.statePlans, this.operationTarget, operation, schedule.entityType, new Date().toISOString(), PURGE_BATCH);
3583
+ return runPurgePass(operation, due, async (entityId) => {
3584
+ await this.#invokeOrThrow(operation, { [due.idFrom]: entityId }, schedule.moduleId, tenantId, scopeId, undefined, undefined, schedule.moduleId, { invocationId: ulid() }, undefined, undefined, undefined, systemDoorInstance, true);
3585
+ }, (write) => write(doSpineSql(this.sql)));
3586
+ }
3587
+ /**
3588
+ * #119: the purge gate's facts (`PurgeGateFacts`), from this scope's own storage: the module's
3589
+ * switch, the lifecycle the platform delivered (#1713), the copy classification (#2009) and the
3590
+ * tenant receipt (`tenantVerdict` — `unknown` passes, as at every door). A directory-backed
3591
+ * deployment records no receipt and delivers neither a lifecycle nor a copy classification here,
3592
+ * so on one those read clear: this object enforces the kill switch, and the coordinator's gate
3593
+ * is the authority for tenant, lifecycle, copy and the rewind hold.
3594
+ */
3595
+ purgeGateFacts(moduleId, tenantId) {
3596
+ const now = new Date().toISOString();
3597
+ return {
3598
+ switched: systemScheduleState(this.switchSql(), moduleId, now),
3599
+ lifecycle: lifecycleRefusal(readLifecycle(this.switchSql())),
3600
+ copy: this.isCopy(),
3601
+ foreignTenant: this.tenantVerdict(tenantId).verdict === 'foreign',
3602
+ };
3603
+ }
3251
3604
  /**
3252
3605
  * The last time a schedule's operation ran on this scope (#383), or null.
3253
3606
  *
@@ -3588,18 +3941,55 @@ export function defineScopeDO(modules, bareOps) {
3588
3941
  this.migrationPromise = this.applyPendingMigrations();
3589
3942
  return this.migrationPromise;
3590
3943
  }
3944
+ /** Every registered module's derivation plans — what `repairDerivedObjects` and its checks read. */
3945
+ derivedPlans() {
3946
+ return { state: this.statePlans, lists: this.listPlans, search: this.searchPlans };
3947
+ }
3948
+ /** A multi-statement script on this DO's own handle, one statement per exec. */
3949
+ runScript(ddl) {
3950
+ for (const stmt of executableSqlStatements(ddl))
3951
+ this.sql.exec(stmt);
3952
+ }
3953
+ /**
3954
+ * #2090: the repair a pass with nothing pending runs, in a transaction of its own. A lost
3955
+ * state column fails the scope closed like a failed migration, named by the journaled
3956
+ * migration that added the column — the pure host's `repairDerived`.
3957
+ */
3958
+ async repairDerived() {
3959
+ try {
3960
+ await this.revision.transaction(async () => {
3961
+ this.revisionSuspended = true;
3962
+ try {
3963
+ repairDerivedObjects(doSpineSql(this.sql), (ddl) => this.runScript(ddl), this.derivedPlans(), {
3964
+ after: 'an applied migration',
3965
+ });
3966
+ }
3967
+ finally {
3968
+ this.revisionSuspended = false;
3969
+ }
3970
+ });
3971
+ }
3972
+ catch (err) {
3973
+ const version = err instanceof StateColumnLost ? err.migration : 'kernel@derived-objects';
3974
+ this.lastFailure = { version, error: err.message };
3975
+ throw migrationFailedError(version, err.message);
3976
+ }
3977
+ }
3591
3978
  /** Resolves true if this call applied at least one migration. */
3592
3979
  async applyPendingMigrations() {
3593
- const pending = [];
3594
- for (const mod of this.modules.values()) {
3595
- for (const migration of mod.migrations) {
3596
- if (!this.applied.has(`${mod.id}@${migration.version}`)) {
3597
- pending.push({ moduleId: mod.id, migration });
3598
- }
3599
- }
3980
+ const { pending, diverged } = await planMigrations(this.modules.values(), this.applied);
3981
+ if (diverged) {
3982
+ // Recorded as a failed migration is, so the coordinator projects it the same way.
3983
+ this.lastFailure = diverged;
3984
+ throw migrationFailedError(diverged.version, diverged.error);
3600
3985
  }
3601
- if (pending.length === 0)
3986
+ if (pending.length === 0) {
3987
+ // #2090: once per pass — so once per wake, and on every `retryMigrations` — check and
3988
+ // repair what the kernel derived onto the tables. Their migrations are journaled and will
3989
+ // never run again, so this is what reaches a scope stripped before the pass checked.
3990
+ await this.queue.enqueue(async () => this.repairDerived());
3602
3991
  return false;
3992
+ }
3603
3993
  this.migrationRuns += 1;
3604
3994
  await this.queue.enqueue(async () => {
3605
3995
  // #286: bookmark the instant before an UPGRADE migrates live data — the
@@ -3622,34 +4012,53 @@ export function defineScopeDO(modules, bareOps) {
3622
4012
  // (#278) remains the fallback rewind point.
3623
4013
  }
3624
4014
  }
3625
- for (const { moduleId, migration } of pending) {
4015
+ for (const [i, { moduleId, migration, digest, authored }] of pending.entries()) {
3626
4016
  const key = `${moduleId}@${migration.version}`;
3627
4017
  if (this.applied.has(key))
3628
4018
  continue;
4019
+ let recorded = digest;
3629
4020
  try {
3630
4021
  await this.revision.transaction(async () => {
3631
4022
  const already = this.sql
3632
- .exec('SELECT 1 FROM _substrat_migrations WHERE module_id = ? AND version = ?', moduleId, migration.version)
4023
+ .exec('SELECT sql_digest FROM _substrat_migrations WHERE module_id = ? AND version = ?', moduleId, migration.version)
3633
4024
  .toArray()[0];
3634
- if (!already) {
4025
+ if (already) {
4026
+ // Applied since this pass read the journal: held to the same digest rule.
4027
+ const error = migrationDivergence(already.sql_digest, digest, authored);
4028
+ if (error)
4029
+ throw new Error(error);
4030
+ recorded = already.sql_digest;
4031
+ }
4032
+ else {
3635
4033
  const started = performance.now();
3636
4034
  const before = this.sql.exec('SELECT total_changes() AS n').toArray()[0].n;
3637
- // #1898: a migration runs on this DO's own handle, not `ctx.sql`, so the
3638
- // spine guard's REFERENCES rule is applied here.
3639
- assertNoSpineReference(migration.sql, `migration ${key}`);
4035
+ // #1898, #2066: a migration runs on this DO's own handle, not `ctx.sql`, so the
4036
+ // spine rules a migration is held to are applied here.
4037
+ assertMigrationSql(migration.sql, { key, digest, authored });
4038
+ // #2068: the ownership ledger is the kernel's — no migration may name it, not even to
4039
+ // read it (the spine rules above allow reads).
4040
+ assertMigrationLeavesLedgerAlone(migration.sql, `migration ${key}`);
3640
4041
  // #1722: not counted per statement, so `total_changes()` measures the migration
3641
4042
  // alone. The journal row below is a write, and advances the revision once.
4043
+ // #2068: run one statement at a time, keeping the table set either side of each.
4044
+ let steps;
3642
4045
  this.revisionSuspended = true;
3643
4046
  try {
3644
- for (const stmt of splitSqlStatements(migration.sql)) {
4047
+ steps = runMigrationStatements(doSpineSql(this.sql), migration.sql, (stmt) => {
3645
4048
  this.sql.exec(stmt);
3646
- }
4049
+ });
4050
+ // #2090: the state columns this migration must have left, and after the last of
4051
+ // the pass, the triggers and indexes a create-copy-rename rebuild dropped.
4052
+ const last = i === pending.length - 1;
4053
+ afterMigration(doSpineSql(this.sql), (ddl) => this.runScript(ddl), this.derivedPlans(), key, last);
3647
4054
  }
3648
4055
  finally {
3649
4056
  this.revisionSuspended = false;
3650
4057
  }
3651
4058
  const after = this.sql.exec('SELECT total_changes() AS n').toArray()[0].n;
3652
- this.sql.exec('INSERT INTO _substrat_migrations (module_id, version, applied_at, duration_ms, rows_changed) VALUES (?, ?, ?, ?, ?)', moduleId, migration.version, new Date().toISOString(), Math.max(0, Math.round(performance.now() - started)), after - before);
4059
+ // After the count: `rows_changed` is the migration's own, never the kernel's bookkeeping.
4060
+ recordOwnershipSteps(doSpineSql(this.sql), moduleId, steps, new Date().toISOString());
4061
+ this.sql.exec('INSERT INTO _substrat_migrations (module_id, version, applied_at, duration_ms, rows_changed, sql_digest) VALUES (?, ?, ?, ?, ?, ?)', moduleId, migration.version, new Date().toISOString(), Math.max(0, Math.round(performance.now() - started)), after - before, digest);
3653
4062
  }
3654
4063
  });
3655
4064
  }
@@ -3663,9 +4072,9 @@ export function defineScopeDO(modules, bareOps) {
3663
4072
  // It is not an unhandled rejection: every caller awaits the memoised promise, and
3664
4073
  // the coordinator records the failure it receives (#1898 review).
3665
4074
  this.lastFailure = { version: key, error: err.message };
3666
- throw new Error(`migration failed for ${key} — scope fails closed: ${err.message}`);
4075
+ throw migrationFailedError(key, err.message);
3667
4076
  }
3668
- this.applied.add(key);
4077
+ this.applied.set(key, recorded);
3669
4078
  }
3670
4079
  });
3671
4080
  return true;
@@ -3904,6 +4313,30 @@ export function defineScopeDO(modules, bareOps) {
3904
4313
  attempts = attempts + 1, settled_at = ?
3905
4314
  WHERE id = ? AND status = 'pending'`, status, result, lastError, lastFailure, status === 'pending' ? null : new Date().toISOString(), id);
3906
4315
  }
4316
+ /**
4317
+ * A settle that writes an outcome event (#2102): the kernel's one settle — the same
4318
+ * compare-and-set as the verb above — and the event in its transaction, written only when
4319
+ * this settle moved the row out of `pending`. Then the event reaches this scope's consumers,
4320
+ * as any committed write's does.
4321
+ *
4322
+ * A verb of its own rather than an argument on the one above, so a settle carrying no event
4323
+ * keeps reaching a DO class older than this change; only the new kind's settle needs it.
4324
+ */
4325
+ async settlePlatformRequestWithEvent(tenantId, scopeId, id, outcome) {
4326
+ await this.ensureMigrations();
4327
+ return this.queue.enqueue(async () => {
4328
+ const liveSince = this.liveHighWaterMark();
4329
+ this.revision.transactionSync(() => settlePlatformRequestIn(this.switchSql(), id, outcome, {
4330
+ tenantId,
4331
+ scopeId,
4332
+ now: new Date().toISOString(),
4333
+ mintEventId: (ms) => this.mintEventId(ms),
4334
+ version: this.env.SUBSTRAT_VERSION_ID ?? null,
4335
+ }));
4336
+ // #1525: null — a settle is the platform's call, not one a vertical's caller made.
4337
+ await this.settleCommitted(tenantId, scopeId, liveSince, null);
4338
+ });
4339
+ }
3907
4340
  /**
3908
4341
  * Turn one connector delivery into a `connector:<provider>` platform intent (#574
3909
4342
  * phase 3) — the CP-less host's substitute for running the handler it cannot run.
@@ -3997,7 +4430,7 @@ export function defineScopeDO(modules, bareOps) {
3997
4430
  /** Every table in this scope's DB, with row counts; spine/internal tables flagged. */
3998
4431
  introspectTables() {
3999
4432
  const names = this.sql
4000
- .exec(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`)
4433
+ .exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB '_cf_*' ORDER BY name`)
4001
4434
  .toArray().map((r) => r.name);
4002
4435
  return names.map((name) => ({
4003
4436
  name,
@@ -4021,7 +4454,7 @@ export function defineScopeDO(modules, bareOps) {
4021
4454
  }
4022
4455
  tablePage(table, limit, offset) {
4023
4456
  const known = new Set(this.sql
4024
- .exec(`SELECT name FROM sqlite_master WHERE type = 'table'`)
4457
+ .exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB '_cf_*'`)
4025
4458
  .toArray().map((r) => r.name));
4026
4459
  if (!known.has(table))
4027
4460
  throw substratError('not_found', `unknown table '${table}'`);
@@ -4059,6 +4492,7 @@ export function defineScopeDO(modules, bareOps) {
4059
4492
  }
4060
4493
  async readOnlyQuery(sql) {
4061
4494
  const stmt = assertReadOnlyQuery(sql);
4495
+ assertNoWorkerdStorage(stmt);
4062
4496
  let result;
4063
4497
  const rollback = new Error('read-only console rollback');
4064
4498
  try {
@@ -4136,7 +4570,7 @@ export function defineScopeDO(modules, bareOps) {
4136
4570
  // carries the rows, never the index over them.
4137
4571
  const defs = this.sql
4138
4572
  .exec(`SELECT name, sql FROM sqlite_master
4139
- WHERE type = 'table' AND name NOT GLOB 'sqlite_*' AND sql IS NOT NULL
4573
+ WHERE type = 'table' AND name NOT GLOB 'sqlite_*' AND name NOT GLOB '_cf_*' AND sql IS NOT NULL
4140
4574
  ORDER BY name`)
4141
4575
  .toArray().filter(({ name }) => !isSearchIndexTable(name));
4142
4576
  return defs.map(({ name, sql }) => {
@@ -4199,7 +4633,11 @@ export function defineScopeDO(modules, bareOps) {
4199
4633
  // #1763: rows written before these fields keep NULL, meaning unrecorded.
4200
4634
  'ALTER TABLE _substrat_migrations ADD COLUMN duration_ms INTEGER',
4201
4635
  'ALTER TABLE _substrat_migrations ADD COLUMN rows_changed INTEGER',
4636
+ // #2066: the rows already there get the legacy mark below, never a digest.
4637
+ 'ALTER TABLE _substrat_migrations ADD COLUMN sql_digest TEXT',
4202
4638
  'ALTER TABLE _substrat_tuples ADD COLUMN revoked_at TEXT',
4639
+ // #2126: NULL keeps every existing capability's attachment behavior unchanged.
4640
+ 'ALTER TABLE _substrat_capabilities ADD COLUMN attachments TEXT',
4203
4641
  // #1632: legacy runs retain an unknown subject; no content-based backfill.
4204
4642
  'ALTER TABLE _substrat_job_runs ADD COLUMN subject_id TEXT',
4205
4643
  // #2034: the lease. NULL = nobody holds the run, which is right for every row already there.
@@ -4255,6 +4693,9 @@ export function defineScopeDO(modules, bareOps) {
4255
4693
  // every legacy row — a past run's id cannot be recovered afterwards, exactly
4256
4694
  // as the other #1525 columns above argued.
4257
4695
  'ALTER TABLE _substrat_schedule_state ADD COLUMN invocation_id TEXT',
4696
+ // #2096: a purge horizon's lap in progress, on a scope DO built before the column. NULL is
4697
+ // the start of a lap — the honest reading of every row already there.
4698
+ 'ALTER TABLE _substrat_schedule_state ADD COLUMN purge_cursor TEXT',
4258
4699
  // #2009: the copy classification on a scope DO built before it (NULL reads as a copy;
4259
4700
  // see `COPY_ORIGIN_DDL`).
4260
4701
  'ALTER TABLE _substrat_copy_origin ADD COLUMN is_copy INTEGER',
@@ -4280,6 +4721,9 @@ export function defineScopeDO(modules, bareOps) {
4280
4721
  // boot. `lint:spine-ddl` compares KERNEL_DDL's indexes only, so this one is held to
4281
4722
  // both adapters by the query-plan test rather than by that gate.
4282
4723
  this.sql.exec('CREATE INDEX IF NOT EXISTS _substrat_outbox_invocation ON _substrat_outbox (invocation_id, id)');
4724
+ this.sql.exec(GRANT_CHILDREN_INDEX_DDL);
4725
+ // #2066: what they ran was never measured. KERNEL_DDL's fence keeps any other NULL out.
4726
+ this.sql.exec(MIGRATION_DIGEST_MARK_LEGACY);
4283
4727
  this.ensureScheduleStateKind();
4284
4728
  this.ensureRefusalsAdmitGuards();
4285
4729
  }
@@ -4320,11 +4764,11 @@ export function defineScopeDO(modules, bareOps) {
4320
4764
  // synchronous, which is the one case the sync API is for (it commits at the
4321
4765
  // first await, and there is none). It also has to be sync because the caller is.
4322
4766
  this.revision.transactionSync(() => {
4323
- for (const stmt of splitSqlStatements(script))
4767
+ for (const stmt of executableSqlStatements(script))
4324
4768
  this.sql.exec(stmt);
4325
4769
  });
4326
4770
  }
4327
- async importDump(tables, destScopeId, { switchOff, sourceScopeId, exact, loadStamp, expect, resolveKept, markCopy, provisionedFor, } = {}) {
4771
+ async importDump(tables, destScopeId, { switchOff, sourceScopeId, exact, loadStamp, expect, resolveKept, markCopy, provisionedFor, fence, } = {}) {
4328
4772
  // The WHOLE drop-then-replay runs under deferred foreign keys, in one transaction.
4329
4773
  //
4330
4774
  // Two distinct FK hazards, and both need the deferral — the second is why it cannot
@@ -4360,6 +4804,13 @@ export function defineScopeDO(modules, bareOps) {
4360
4804
  throw substratError('validation_failed', 'restore refused: `exact` needs the scope the dump came from');
4361
4805
  }
4362
4806
  const switched = await this.revision.transaction(async () => {
4807
+ // #1722: first, before any read: a store a reap has since emptied must refuse the late load
4808
+ // too, and the check is against this store's clock, inside the load's own transaction.
4809
+ if (fence && copyRestoreFenceLapsed(fence, Date.now())) {
4810
+ const lapsed = substratError('precondition_failed', COPY_RESTORE_FENCE_LAPSED);
4811
+ fenceRefusals.add(lapsed);
4812
+ throw lapsed;
4813
+ }
4363
4814
  const before = this.loadMarker();
4364
4815
  // #1713: the lifecycle the platform delivered here, read before the drops take it.
4365
4816
  const lifecycleBefore = readLifecycle(this.switchSql());
@@ -4397,7 +4848,9 @@ export function defineScopeDO(modules, bareOps) {
4397
4848
  this.sql.exec('PRAGMA defer_foreign_keys = ON');
4398
4849
  // Real tables only; `sqlite_*` internals are auto-managed and un-droppable.
4399
4850
  const existing = this.sql
4400
- .exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB 'sqlite_*'`)
4851
+ // Nor workerd's own `_cf_*` (the live reaper's alarm keeps `_cf_METADATA`), which
4852
+ // it refuses to drop.
4853
+ .exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB 'sqlite_*' AND name NOT GLOB '_cf_*'`)
4401
4854
  .toArray();
4402
4855
  // Search index tables are left alone here and rebuilt below (#827): dropping a
4403
4856
  // shadow table directly is an error, and `sqlite_master` order would reach one
@@ -4412,9 +4865,12 @@ export function defineScopeDO(modules, bareOps) {
4412
4865
  // decide how this DO's permission checks match. Every `_substrat_*` table is built
4413
4866
  // from KERNEL_DDL instead, and the dump contributes only rows, by column name
4414
4867
  // (`spineRowsInsert`), a missing column taking the kernel's default.
4868
+ // Comment-blanked (#2068): a dump records `sqlite_master.sql` verbatim, comments included,
4869
+ // and replaying it raw would store them again — and a later DROP COLUMN of the last
4870
+ // column fails on workerd. The validated statement is otherwise executed exactly as is.
4415
4871
  for (const t of replayable)
4416
4872
  if (!isSpineTable(t.name))
4417
- this.sql.exec(t.ddl);
4873
+ this.sql.exec(blankSqlComments(t.ddl));
4418
4874
  // KERNEL_DDL also builds what the dump did not carry (#321). A dump captured from a
4419
4875
  // WORLD that stores some `_substrat_*` tables ELSEWHERE carries only a subset — an
4420
4876
  // `@substrat-run/adapter-sqlite` scope file keeps `_substrat_roles` /
@@ -4424,11 +4880,12 @@ export function defineScopeDO(modules, bareOps) {
4424
4880
  // restore's repair leg (host.projectRolesLocal) — the spine's job is only to exist so
4425
4881
  // the checker can read it. The column pass follows, for the one outbox index KERNEL_DDL
4426
4882
  // leaves to it.
4427
- for (const stmt of splitSqlStatements(KERNEL_DDL))
4883
+ for (const stmt of executableSqlStatements(KERNEL_DDL))
4428
4884
  this.sql.exec(stmt);
4429
4885
  this.applySpineColumnAdditions();
4430
4886
  const columnsOf = (name) => doBuiltColumnsOf(this.sql, name);
4431
4887
  assertSpineTablesBuilt(replayable.map((t) => t.name), columnsOf);
4888
+ assertJournalDumpCoherent(replayable);
4432
4889
  // A spine column this kernel does not know (a dump from a newer one) is kept, as a plain
4433
4890
  // untyped column the checker never reads.
4434
4891
  for (const t of replayable) {
@@ -4479,6 +4936,17 @@ export function defineScopeDO(modules, bareOps) {
4479
4936
  if (destScopeId) {
4480
4937
  this.rewriteScopeTuples(destScopeId, sourceScopeId && { scopeId: sourceScopeId, exact }, new Date().toISOString());
4481
4938
  }
4939
+ // #827 / #119 / #811: the search triggers, the archive/trash guard triggers, the purge
4940
+ // sweep's index and the derived list indexes went with the dropped tables, and the search index points at rows
4941
+ // that are gone. Repaired, and the search index rebuilt, AFTER the rows — which may
4942
+ // legitimately arrive archived or trashed — as the dump's own journal says they were
4943
+ // derived (#2090). Inside the replay's transaction: a dump whose journal owes a table a
4944
+ // state column it does not carry rolls the whole load back. A plan whose table this dump
4945
+ // did not carry is skipped: a restore must not invent one.
4946
+ repairDerivedObjects(doSpineSql(this.sql), (ddl) => this.runScript(ddl), this.derivedPlans(), {
4947
+ after: 'the restored dump',
4948
+ absentTable: 'skip',
4949
+ });
4482
4950
  // #1742: the recorded-off modules go back off INSIDE the replay's transaction, with
4483
4951
  // the spine and the re-point. A switch that throws rolls the whole restore back, so
4484
4952
  // the dump's grants never commit live without the switch that should cover them.
@@ -4491,37 +4959,6 @@ export function defineScopeDO(modules, bareOps) {
4491
4959
  }
4492
4960
  });
4493
4961
  this.carriedAwayCopy = this.metaValue(CARRIED_AWAY_KEY) !== null;
4494
- // Rebuild the derived search indexes over the rows just loaded (#827). Drop-then-
4495
- // create, so it also repairs an index a dump left stale, and the triggers it
4496
- // recreates are what keep the restored scope in step from here. Skipped for a plan
4497
- // whose content table this dump did not carry — a restore must not invent a table
4498
- // for an index to point at.
4499
- const present = new Set(this.sql
4500
- .exec(`SELECT name FROM sqlite_master WHERE type = 'table'`)
4501
- .toArray().map((r) => r.name));
4502
- for (const plan of this.searchPlans.values()) {
4503
- if (!present.has(plan.table))
4504
- continue;
4505
- for (const stmt of splitSqlStatements(searchIndexDdl(plan)))
4506
- this.sql.exec(stmt);
4507
- }
4508
- // #119: the never-born-archived trigger went with the dropped table. Put back AFTER the
4509
- // rows, which may legitimately arrive archived or trashed.
4510
- for (const plan of this.statePlans.values()) {
4511
- if (!present.has(plan.table))
4512
- continue;
4513
- for (const stmt of splitSqlStatements(entityStateTriggerDdl(plan)))
4514
- this.sql.exec(stmt);
4515
- }
4516
- // #811 / #119: the derived list indexes went with the dropped table too, and a load never
4517
- // put them back — an archivable entity's partial indexes are part of what the kernel
4518
- // checks after DDL.
4519
- for (const plan of this.listPlans.values()) {
4520
- if (!present.has(plan.table))
4521
- continue;
4522
- for (const stmt of splitSqlStatements(listIndexDdl(plan)))
4523
- this.sql.exec(stmt);
4524
- }
4525
4962
  // #1335 / #1686: the outbox arrived with the dump, so this DO's event ids resume above it,
4526
4963
  // as on a wake. A copy's own events then sort above every copied one, which is what
4527
4964
  // `emittedHere()` relies on. A dump whose top id is no ULID leaves the floor where it was.
@@ -4536,12 +4973,7 @@ export function defineScopeDO(modules, bareOps) {
4536
4973
  }
4537
4974
  // The frontier arrived with the dump — refresh the in-memory applied set so a
4538
4975
  // later migrate() builds on the imported state, not the provisioning state.
4539
- this.applied.clear();
4540
- for (const row of this.sql
4541
- .exec('SELECT module_id, version FROM _substrat_migrations')
4542
- .toArray()) {
4543
- this.applied.add(`${row.module_id}@${row.version}`);
4544
- }
4976
+ this.applied = readAppliedMigrations(this.sql);
4545
4977
  // …and forget that this INSTANCE ever ran a migration pass (#1589). Refreshing
4546
4978
  // the set above is not enough on its own: `ensureMigrations` memoises its
4547
4979
  // promise, so a warm DO answers "already migrated" from the cache and never
@@ -4565,6 +4997,12 @@ export function defineScopeDO(modules, bareOps) {
4565
4997
  // is complete applies nothing, so there is nothing to report either way.
4566
4998
  this.migrationPromise = undefined;
4567
4999
  this.lastFailure = null;
5000
+ // The KV key never travels in a SQL dump. A load into this same DO would
5001
+ // otherwise retain its old epoch, so explicitly invalidate all old tokens.
5002
+ await this.ctx.storage.delete('continuation:keys');
5003
+ const oldPositions = await this.ctx.storage.list({ prefix: 'continuation:position:' });
5004
+ for (const name of oldPositions.keys())
5005
+ await this.ctx.storage.delete(name);
4568
5006
  return switched;
4569
5007
  }
4570
5008
  /**
@@ -4577,6 +5015,8 @@ export function defineScopeDO(modules, bareOps) {
4577
5015
  return { refused: false, switchedOff: await this.importDump(tables, destScopeId, opts) };
4578
5016
  }
4579
5017
  catch (e) {
5018
+ if (e instanceof Error && fenceRefusals.has(e))
5019
+ return { refused: 'lapsed' };
4580
5020
  const code = errorCodeOf(e);
4581
5021
  if (code === 'precondition_failed')
4582
5022
  return { refused: 'changed' };
@@ -4801,9 +5241,41 @@ export function defineScopeDO(modules, bareOps) {
4801
5241
  * outranks their Article 17 right.
4802
5242
  */
4803
5243
  async redactSubject(subjectId) {
5244
+ // #2068: the module half reaches the scope's own tables, so they have to exist.
5245
+ await this.ensureMigrations();
4804
5246
  // One instant for the whole erasure — the intent tombstones must not disagree with
4805
5247
  // each other about when a person was erased.
4806
5248
  const at = new Date().toISOString();
5249
+ // ONE transaction (#2068): a module's `onSubjectErased` hook that throws rolls the
5250
+ // whole redaction back, the spine half with it, and the coordinator — which destroys
5251
+ // the key only after this returns — never reaches the key.
5252
+ //
5253
+ // Its refusal is answered as DATA (`toWireFailure`, #113): workerd delivers a throw across
5254
+ // this boundary as its message alone, and a hook's `forbidden` (it reached past its own
5255
+ // tables) is a different answer from an `internal`. In this method's own reply rather than
5256
+ // a `…Reply` sibling, so every coordinator — old ones too — keeps calling one verb: an old
5257
+ // one reads a failure as a reply missing its counts and refuses before the key, which is
5258
+ // the safe reading.
5259
+ try {
5260
+ return this.revision.transactionSync(() => this.redactSubjectInTransaction(subjectId, at));
5261
+ }
5262
+ catch (err) {
5263
+ return { failure: toWireFailure(err) };
5264
+ }
5265
+ }
5266
+ /** The body of `redactSubject`, inside its transaction. */
5267
+ redactSubjectInTransaction(subjectId, at) {
5268
+ // The module half first (#2068): the declared entities, then each hook, with the search
5269
+ // indexes over them under FTS5 secure-delete. The spine half follows in the same transaction.
5270
+ const vertical = eraseSubjectFromModules({
5271
+ sql: doSpineSql(this.sql),
5272
+ plans: this.erasurePlans,
5273
+ searchPlans: this.searchPlans.values(),
5274
+ statefulTables: statefulTablesOf(this.statePlans),
5275
+ subjectId,
5276
+ at,
5277
+ migrationSqlOf: (moduleId, version) => this.modules.get(moduleId)?.migrations.find((m) => m.version === version)?.sql,
5278
+ });
4807
5279
  const doomed = this.sql
4808
5280
  .exec(`SELECT id FROM _substrat_outbox
4809
5281
  WHERE subject_id = ? AND pii_class != 'none' AND payload IS NOT NULL`, subjectId)
@@ -4824,6 +5296,7 @@ export function defineScopeDO(modules, bareOps) {
4824
5296
  // The free-text copies (#1632), and the tombstoned intents the coordinator hands to
4825
5297
  // the directory half. Last, so those ids include every intent tombstoned above.
4826
5298
  ...redactSubjectScopeText(sql, subjectId, at),
5299
+ vertical,
4827
5300
  };
4828
5301
  }
4829
5302
  /**
@@ -5065,7 +5538,9 @@ export function defineScopeDO(modules, bareOps) {
5065
5538
  */
5066
5539
  peerSubject,
5067
5540
  /** #2055: the delivery a consumer's or an import's context runs for; absent otherwise. */
5068
- delivery) {
5541
+ delivery,
5542
+ /** #119: keys this context's checks refuse whatever the subject holds (`purgeOnlyKeysOf`). */
5543
+ withheld) {
5069
5544
  const checker = this.checker;
5070
5545
  const relations = this.relations;
5071
5546
  const searchPlans = this.searchPlans;
@@ -5126,6 +5601,9 @@ export function defineScopeDO(modules, bareOps) {
5126
5601
  // #1642: parsed before the system actor's early return, which never reaches
5127
5602
  // the checker — a cast key would otherwise become that path's proof relation.
5128
5603
  const permission = assertPermissionKey(unparsed);
5604
+ if (withheld?.has(permission)) {
5605
+ return { allowed: false, checked: permission, node: { tenantId, scopeId } };
5606
+ }
5129
5607
  if (systemActor) {
5130
5608
  return {
5131
5609
  allowed: true,
@@ -5206,10 +5684,12 @@ export function defineScopeDO(modules, bareOps) {
5206
5684
  // #1672: a capability's own id stands in so the type holds — it is not a person, and
5207
5685
  // the event actor says what it is instead. Every other door passes its own value.
5208
5686
  principal: capabilityId ? capabilityId : principal,
5687
+ pageContinuation: (list, query) => visibleContinuation(this.continuationStore(impersonation?.mode !== 'read-only' && !this.isCopy()), { scopeId, principal: `${subject.kind}:${subject.id}`, operation: operation ?? 'kernel', list, query }, Date.now, () => ctxRef.log.info('legacy filtered-list cursor accepted', { list }), impersonation?.mode !== 'read-only' && !this.isCopy()),
5209
5688
  sql: guardSecrets(doScopedSql(sql, statefulTablesOf(statePlans),
5210
- // #119: after runtime DDL, the stateful tables must still carry what the kernel derived.
5211
- statePlans.size
5212
- ? () => assertEntityStateIntact(doSpineSql(sql), statePlans, stateListIndexNames(listPlans.values()))
5689
+ // #119 / #2090: after runtime DDL, inside this operation's transaction, what the kernel
5690
+ // derived onto any table is repaired — and a lost state column fails the operation.
5691
+ derivesAnything(this.derivedPlans())
5692
+ ? () => afterRuntimeDdl(doSpineSql(sql), (ddl) => this.runScript(ddl), this.derivedPlans())
5213
5693
  : undefined), minted),
5214
5694
  now: () => at,
5215
5695
  // #1746/#1747: the host stamps who and where; module code supplies only the template
@@ -5268,6 +5748,10 @@ export function defineScopeDO(modules, bareOps) {
5268
5748
  return entityVersionOf(sql.exec(q.sql, ...q.params).toArray());
5269
5749
  },
5270
5750
  check: runCheck,
5751
+ grantedEntities: async (unparsed, entityType, options) => {
5752
+ const permission = assertPermissionKey(unparsed);
5753
+ return grantedEntitiesForContext(checker, subject, permission, { tenantId, scopeId }, entityType, options, runCheck, withheld, Boolean(systemActor));
5754
+ },
5271
5755
  canAssign: runCanAssign,
5272
5756
  // #827. Mirror of the pure adapter: the plan comes from registration, the
5273
5757
  // rows from this DO's own SQLite, and the index is maintained by triggers
@@ -5303,15 +5787,14 @@ export function defineScopeDO(modules, bareOps) {
5303
5787
  view: uncheckedView('ctx.page', entityType, params.view),
5304
5788
  });
5305
5789
  const rows = sql.exec(q.sql, ...q.params).toArray();
5306
- const last = rows.length >= limit ? rows[rows.length - 1] : undefined;
5307
- const nextCursor = last === undefined ? null : cursorOf(last, q.sortColumn, plan.idColumn, q.order, q.view);
5308
- const page = { entries: rows, nextCursor };
5790
+ // `pageOf`'s rule, and each row's own cursor when asked (#2073).
5791
+ const mint = (row) => cursorOf(row, q.sortColumn, plan.idColumn, q.order, q.view);
5309
5792
  if (!params.total)
5310
- return page;
5793
+ return pageOf(rows, limit, mint, params.rowCursors);
5311
5794
  const counted = sql
5312
5795
  .exec(q.countSql, ...q.countParams)
5313
5796
  .toArray();
5314
- return { ...page, total: counted[0]?.n ?? 0 };
5797
+ return countedPageOf(rows, limit, mint, counted[0]?.n ?? 0, params.rowCursors);
5315
5798
  },
5316
5799
  /**
5317
5800
  * Delegate a permission this caller holds onto one entity — see the
@@ -5329,7 +5812,8 @@ export function defineScopeDO(modules, bareOps) {
5329
5812
  throw new PermissionDenied(`cannot grant '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
5330
5813
  'the caller does not hold it there (a grant delegates, it never elevates)');
5331
5814
  }
5332
- sql.exec(`INSERT OR IGNORE INTO _substrat_tuples (subject, relation, object) VALUES (?, ?, ?)`, `principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`);
5815
+ // #2071: an explicit grant, so it clears a tombstone `revoke` left.
5816
+ writeExplicitTupleIn(this.switchSql(), `principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`, { kind: 'delegated' });
5333
5817
  },
5334
5818
  /**
5335
5819
  * Deliberately NOT the #1856 grammar check `grant` and `link` make: a revoke writes
@@ -5343,7 +5827,10 @@ export function defineScopeDO(modules, bareOps) {
5343
5827
  throw new PermissionDenied(`cannot revoke '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
5344
5828
  'the caller does not hold it there');
5345
5829
  }
5346
- sql.exec(`DELETE FROM _substrat_tuples WHERE subject = ? AND relation = ? AND object = ?`, `principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`);
5830
+ // K-21 (#2071): a tombstone, never a delete — a declared shape's top-up must be
5831
+ // able to tell a key taken back from one never held.
5832
+ const r = delegatedRevokeSql(principal, permission, `${entity.entityType}:${entity.entityId}`, at);
5833
+ sql.exec(r.sql, ...r.params);
5347
5834
  },
5348
5835
  atomic: createAtomic(runSub, { passed, signals }),
5349
5836
  // #1672: mint / revoke / list, written once in the kernel — the pure adapter hands
@@ -5369,6 +5856,7 @@ export function defineScopeDO(modules, bareOps) {
5369
5856
  now: at,
5370
5857
  emit: (event) => writeEvent(event, 'kernel'),
5371
5858
  assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
5859
+ isUnreachableParent: (entity) => isUnreachableParent(doSpineSql(sql), statePlans, entity),
5372
5860
  }),
5373
5861
  // #119: archive and trash — the pure adapter's wiring, over the raw spine seam.
5374
5862
  ...createEntityStateVerbs({
@@ -5385,6 +5873,7 @@ export function defineScopeDO(modules, bareOps) {
5385
5873
  searchPlans,
5386
5874
  statePlans,
5387
5875
  check: runCheck,
5876
+ continuation: (entityType, params) => ctxRef.pageContinuation(`trash:${entityType}`, { ...params, view: 'trashed' }),
5388
5877
  }),
5389
5878
  entitlement: async (key) => {
5390
5879
  const held = await entitlementReader().listEntitlements(tenantId);
@@ -5427,6 +5916,29 @@ export function defineScopeDO(modules, bareOps) {
5427
5916
  .toArray()[0];
5428
5917
  return row?.value === 'local' ? 'local' : 'control-plane';
5429
5918
  }
5919
+ /**
5920
+ * Whether a check here reads the directory over RPC (#938, Codex #2077 r5): the same
5921
+ * choice `controlPlaneReader` makes per call, asked each time a live fan-out pass opens an epoch.
5922
+ */
5923
+ permissionSourceIsRemote() {
5924
+ return this.permissionSource() !== 'local' && Boolean(this.env.CONTROL_PLANE);
5925
+ }
5926
+ /**
5927
+ * The earliest `expires_at` after `now` in any local store a check or the parent walk
5928
+ * reads (#938, Codex #2077 r5), or null when nothing here lapses. Until then, no grant,
5929
+ * tenant tuple, parent edge or entitlement this scope holds can stop authorizing without
5930
+ * a write. Revoked rows are not excluded: an earlier bound only costs a re-check.
5931
+ */
5932
+ authorityLapsesAfter(now) {
5933
+ const row = this.sql
5934
+ .exec(`SELECT MIN(e) AS until FROM (
5935
+ SELECT MIN(expires_at) AS e FROM _substrat_tuples WHERE expires_at > ?
5936
+ UNION ALL SELECT MIN(expires_at) FROM _substrat_tenant_tuples WHERE expires_at > ?
5937
+ UNION ALL SELECT MIN(expires_at) FROM _substrat_entitlements WHERE expires_at > ?
5938
+ )`, now, now, now)
5939
+ .toArray()[0];
5940
+ return row?.until ?? null;
5941
+ }
5430
5942
  /**
5431
5943
  * The checker's tenant-tuple/role reader. Chosen **per call** (the source can
5432
5944
  * flip at runtime, and the checker holds this wrapper for the DO's lifetime):