@substrat-run/adapter-cloudflare 0.139.0 → 0.140.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,22 @@
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
11
  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';
12
+ import { settlePlatformRequestIn } from '@substrat-run/kernel';
13
+ 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';
14
+ import { GRANT_CHILDREN_INDEX_DDL, grantedEntitiesForContext } from '@substrat-run/kernel';
15
+ /** A scope's migration journal: `module@version` → the SQL digest it recorded (#2066). */
16
+ function readAppliedMigrations(sql) {
17
+ const rows = sql.exec('SELECT module_id, version, sql_digest FROM _substrat_migrations').toArray();
18
+ return new Map(rows.map((r) => [`${r.module_id}@${r.version}`, r.sql_digest]));
19
+ }
13
20
  /**
14
21
  * The key marking a scope DO whose storage was destroyed (`destroyStorage`).
15
22
  * Written after `deleteAll()` so it survives the wipe, and never cleared: `reaped`
@@ -179,8 +186,13 @@ const KERNEL_DDL = `
179
186
  applied_at TEXT NOT NULL,
180
187
  duration_ms INTEGER,
181
188
  rows_changed INTEGER,
189
+ -- #2066: SHA-256 of the SQL that ran (kernel migrationDigest). NULL on a row written
190
+ -- before the column: unrecorded, accepted, never backfilled.
191
+ sql_digest TEXT,
182
192
  PRIMARY KEY (module_id, version)
183
193
  );
194
+ -- #2066: no new journal row without its digest (the kernel's comment says why).
195
+ ${MIGRATION_DIGEST_FENCE_DDL}
184
196
  -- #286: the PITR bookmark taken immediately BEFORE a migration pass runs on a
185
197
  -- scope that already holds data -- the precise rewind point a backout restores
186
198
  -- to. Rows live in the same storage they describe, so a rewind erases the rows
@@ -192,6 +204,7 @@ const KERNEL_DDL = `
192
204
  pending TEXT NOT NULL
193
205
  );
194
206
  ${SWITCH_FENCES_DDL}
207
+ ${TABLE_OWNERS_DDL}
195
208
  CREATE TABLE IF NOT EXISTS _substrat_tuples (
196
209
  subject TEXT NOT NULL,
197
210
  relation TEXT NOT NULL,
@@ -202,6 +215,7 @@ const KERNEL_DDL = `
202
215
  revoked_at TEXT,
203
216
  PRIMARY KEY (subject, relation, object)
204
217
  );
218
+ ${GRANT_CHILDREN_INDEX_DDL};
205
219
  CREATE TABLE IF NOT EXISTS _substrat_deliveries (
206
220
  event_id TEXT NOT NULL,
207
221
  consumer_module TEXT NOT NULL,
@@ -420,6 +434,17 @@ class WriteRevision {
420
434
  keeping = false;
421
435
  /** A statement's text is almost always a constant, so its answer is remembered (bounded). */
422
436
  writes = new Map();
437
+ /**
438
+ * Every write statement run through this handle since the object woke, in memory (#938, Codex
439
+ * #2077 r4) — counted per STATEMENT, bookkeeping and suspended ones included, where the durable
440
+ * revision above is bumped once per run. A live fan-out pass decides who may hear a frame across
441
+ * awaits; it reads this immediately before deciding and again immediately before sending, with
442
+ * no await between that second read and the send, and decides again when it moved. Per statement
443
+ * because a decision taken between a transaction's first write (its bump) and a later one (the
444
+ * revoke itself) must not read as current. Over-counting (a rolled-back write, a write no check
445
+ * reads) only costs a pass a re-check.
446
+ */
447
+ written = 0;
423
448
  sql;
424
449
  constructor(raw, storage, suspended,
425
450
  /** Why this store takes no write at all right now, or null (a `carried_away` copy, #1722). */
@@ -429,7 +454,10 @@ class WriteRevision {
429
454
  this.suspended = suspended;
430
455
  this.refusal = refusal;
431
456
  const exec = (query, ...bindings) => {
432
- if (this.keeping && this.isWrite(query)) {
457
+ const write = this.isWrite(query);
458
+ if (write)
459
+ this.written++;
460
+ if (this.keeping && write) {
433
461
  // Bookkeeping takes the copy-marker insert and the lifecycle delivery (#1713) and nothing
434
462
  // else: any other write here would be one the revision never saw, which is the hole this
435
463
  // class exists to close.
@@ -438,7 +466,7 @@ class WriteRevision {
438
466
  }
439
467
  return raw.exec(query, ...bindings);
440
468
  }
441
- if (!this.suspended() && this.isWrite(query)) {
469
+ if (write && !this.suspended()) {
442
470
  const refused = this.refusal();
443
471
  if (refused)
444
472
  throw substratError('conflict', refused);
@@ -504,6 +532,10 @@ class WriteRevision {
504
532
  this.covered = false;
505
533
  });
506
534
  }
535
+ /** How many write statements this handle has run (see `written`). */
536
+ get statementsWritten() {
537
+ return this.written;
538
+ }
507
539
  isWrite(query) {
508
540
  let known = this.writes.get(query);
509
541
  if (known === undefined) {
@@ -590,8 +622,6 @@ function cellToJson(v) {
590
622
  * trigger: it passed on better-sqlite3 (whose `exec` takes the whole blob) and
591
623
  * failed every scope on the DO host.
592
624
  */
593
- const IS_CREATE_TRIGGER = /^\s*CREATE\s+(TEMP\s+|TEMPORARY\s+)?TRIGGER\b/i;
594
- const ENDS_WITH_END = /\bEND\s*$/i;
595
625
  /**
596
626
  * The kernel's two-method SQL handle (`SwitchSql`) over a Durable Object's storage — what the
597
627
  * shared switch rules (#1666) and the directory's switch record (#1674) run through. One
@@ -605,67 +635,8 @@ export function switchSqlOver(sql) {
605
635
  },
606
636
  };
607
637
  }
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
- }
638
+ /** The splitter both adapters run migrations through — the kernel's, re-exported for this package's callers. */
639
+ export { splitSqlStatements };
669
640
  /**
670
641
  * #1834: the pin missed. Thrown only by `assertSystemDoor`, and module-private, so no operation can
671
642
  * throw one: the two RPCs a door call reaches turn it into the `SystemDoorMoved` ANSWER, by class
@@ -675,9 +646,51 @@ class SystemDoorMovedError extends Error {
675
646
  }
676
647
  /** #1834: the brand only `assertSystemDoor` sets, so nothing else can make a `SystemDoorPass`. */
677
648
  const SYSTEM_DOOR_PASSED = Symbol('system door passed');
649
+ /** Close a live socket whose session has ended (#938). */
650
+ function closeSessionEnded(ws) {
651
+ try {
652
+ ws.close(LIVE_CLOSE.revoked, 'the session that opened this subscription has ended');
653
+ }
654
+ catch {
655
+ // Already gone.
656
+ }
657
+ }
678
658
  export function defineScopeDO(modules, bareOps) {
679
659
  return class ScopeDO extends DurableObject {
680
660
  sql;
661
+ continuationOrder = 0;
662
+ /** DO storage KV is private to the adapter; neither module SQL nor dumps can read it. */
663
+ continuationStore(writable) {
664
+ const positionKey = (id, expiresAt) => `continuation:position:${String(expiresAt).padStart(13, '0')}:${id}`;
665
+ return {
666
+ keys: async () => (await this.ctx.storage.get('continuation:keys')) ?? null,
667
+ setKeys: async (keys) => { await this.ctx.storage.put('continuation:keys', keys); },
668
+ position: async (id, expiresAt) => {
669
+ if (writable)
670
+ await this.pruneContinuationPositions();
671
+ return (await this.ctx.storage.get(positionKey(id, expiresAt))) ?? null;
672
+ },
673
+ setPosition: async (id, position) => {
674
+ // Several budget stops can land in one millisecond. Keep insertion
675
+ // order explicit so the cap always evicts the first locator minted.
676
+ this.continuationOrder = Math.max(Date.now() * 1_000, this.continuationOrder + 1);
677
+ await this.ctx.storage.put(positionKey(id, position.expiresAt), {
678
+ ...position, order: this.continuationOrder,
679
+ });
680
+ await this.pruneContinuationPositions();
681
+ },
682
+ };
683
+ }
684
+ async pruneContinuationPositions() {
685
+ const positions = await this.ctx.storage.list({ prefix: 'continuation:position:' });
686
+ const live = [...positions].filter(([, record]) => record.expiresAt > Date.now())
687
+ .sort((a, b) => (a[1].order ?? 0) - (b[1].order ?? 0));
688
+ const evict = new Set(live.slice(0, Math.max(0, live.length - CONTINUATION_POSITION_CAP)).map(([name]) => name));
689
+ for (const [name, record] of positions) {
690
+ if (record.expiresAt <= Date.now() || evict.has(name))
691
+ await this.ctx.storage.delete(name);
692
+ }
693
+ }
681
694
  queue = new OperationQueue();
682
695
  operations = new Map();
683
696
  /**
@@ -688,6 +701,12 @@ export function defineScopeDO(modules, bareOps) {
688
701
  operationInput = new Map();
689
702
  /** #129: name → the entity whose version an `If-Match` is compared against. */
690
703
  operationConcurrency = new Map();
704
+ /** #119: name → the entity it addresses by id — the host's trash refusal reads it. */
705
+ operationTarget = new Map();
706
+ /** #119: module id → the keys its system principal holds only for its purge schedules. */
707
+ purgeOnlyKeys = new Map();
708
+ /** #119: a purge schedule's operation → the module that declared it and the entity it purges. */
709
+ purgeSchedules = new Map();
691
710
  /** #116: the operations that declared `idempotency: false` — refusals, not participants. */
692
711
  operationIdempotencyOptOut = new Set();
693
712
  modules = new Map();
@@ -713,6 +732,8 @@ export function defineScopeDO(modules, bareOps) {
713
732
  listPlans = new Map();
714
733
  /** #119: entity type → its archive/trash plan, from every registered module. */
715
734
  statePlans = new Map();
735
+ /** #2068: each registered module's erasure, in registration order — what `redactSubject` runs. */
736
+ erasurePlans = [];
716
737
  /** entityType → the declared attachment gate (#473): read key + write key (default: read). */
717
738
  attachmentTargets = new Map();
718
739
  /**
@@ -736,7 +757,8 @@ export function defineScopeDO(modules, bareOps) {
736
757
  * underneath rows it already stored.
737
758
  */
738
759
  mintEventId = createUlid();
739
- applied = new Set();
760
+ /** `module@version` → the SQL digest its journal row recorded, null for a row from before #2066. */
761
+ applied = new Map();
740
762
  migrationPromise;
741
763
  /** Latch: the applied count is reported to the directory once per DO instance. */
742
764
  schemaVersionReported = false;
@@ -779,7 +801,7 @@ export function defineScopeDO(modules, bareOps) {
779
801
  // transaction on `ctx.storage` itself (`carried-copy.test.ts` in the kernel holds that).
780
802
  this.revision = new WriteRevision(ctx.storage.sql, ctx.storage, () => this.revisionSuspended, () => (this.carriedAwayCopy ? CARRIED_AWAY_WRITE_REFUSAL : null));
781
803
  this.sql = this.revision.sql;
782
- for (const stmt of splitSqlStatements(KERNEL_DDL)) {
804
+ for (const stmt of executableSqlStatements(KERNEL_DDL)) {
783
805
  this.sql.exec(stmt);
784
806
  }
785
807
  this.applySpineColumnAdditions();
@@ -790,11 +812,7 @@ export function defineScopeDO(modules, bareOps) {
790
812
  for (const [name, handler] of Object.entries(bareOps))
791
813
  this.defineOperation(name, handler);
792
814
  // 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
- }
815
+ this.applied = readAppliedMigrations(this.sql);
798
816
  // #1335: and where this DO's event ids have to resume from. A revived DO would
799
817
  // otherwise start its floor at the wall clock, and a clock that has stepped back
800
818
  // — an NTP correction is small but real — mints underneath rows already stored,
@@ -810,6 +828,15 @@ export function defineScopeDO(modules, bareOps) {
810
828
  // -- module registration (port of SqliteScopeHost.registerModule) ---------
811
829
  registerModule(registration) {
812
830
  const manifest = registration.manifest;
831
+ // #119: refused before anything is recorded, as in the pure adapter.
832
+ const trashTargets = registerTrashTargets(manifest.id, new Set(Object.keys(registration.operations ?? {})), registration.operationInputs, manifest.entityStates, manifest.schedules);
833
+ this.purgeOnlyKeys.set(manifest.id, purgeOnlyKeysOf(manifest.schedules ?? []));
834
+ for (const schedule of manifest.schedules ?? []) {
835
+ if (schedule.purge)
836
+ this.purgeSchedules.set(schedule.operation, { moduleId: manifest.id, entityType: schedule.purge.entityType });
837
+ }
838
+ // #2068: refused before anything is recorded, as on the pure host.
839
+ const erasure = moduleErasurePlan(registration);
813
840
  if (manifest.peers?.length)
814
841
  this.peerSources.push({ peers: manifest.peers });
815
842
  // #827: the FTS indexes `searchables` declares, appended after the module's
@@ -841,11 +868,14 @@ export function defineScopeDO(modules, bareOps) {
841
868
  id: manifest.id,
842
869
  // The order the kernel writes once (#1677): authored, then search, then list indexes.
843
870
  migrations: moduleMigrations(registration),
871
+ steps: migrationSteps(registration),
844
872
  consumers: Object.entries(registration.consumers ?? {}).map(([eventType, handler]) => ({
845
873
  eventType,
846
874
  handler,
847
875
  })),
848
876
  });
877
+ if (erasure)
878
+ this.erasurePlans.push(erasure);
849
879
  for (const [name, handler] of Object.entries(registration.predicates ?? {})) {
850
880
  this.predicates.set(name, { module: manifest.id, handler });
851
881
  }
@@ -932,6 +962,9 @@ export function defineScopeDO(modules, bareOps) {
932
962
  if (declaredOptOuts.includes(name) && this.operations.has(name)) {
933
963
  this.operationIdempotencyOptOut.add(name);
934
964
  }
965
+ const target = trashTargets.get(name);
966
+ if (target && this.operations.has(name))
967
+ this.operationTarget.set(name, target);
935
968
  }
936
969
  }
937
970
  defineOperation(name, handler) {
@@ -1571,10 +1604,10 @@ export function defineScopeDO(modules, bareOps) {
1571
1604
  * Provisioning does not come through here; it seats with `seatTuple` (#1659).
1572
1605
  */
1573
1606
  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
- });
1607
+ return replyOf(() => this.queue.enqueue(() => {
1608
+ writeExplicitTupleIn(this.switchSql(), subject, relation, object, { kind: 'replace', expiresAt });
1609
+ return null;
1610
+ }));
1578
1611
  }
1579
1612
  /**
1580
1613
  * Provisioning's scope-tuple write (#1659): create the row if it is missing, follow the
@@ -1604,6 +1637,26 @@ export function defineScopeDO(modules, bareOps) {
1604
1637
  return switchOff ? switchRecordedOff(this.switchSql(), switchOff) : [];
1605
1638
  }));
1606
1639
  }
1640
+ /** A declared entity-grant shape's grant to one person on one entity (#2071), as ONE unit. */
1641
+ async grantEntityShape(principal, entity, permissions) {
1642
+ await this.queue.enqueue(() => this.revision.transactionSync(() => grantEntityShapeIn(this.switchSql(), principal, entity, permissions)));
1643
+ }
1644
+ /**
1645
+ * One bounded pass of a declared shape's reconcile (#2071), with its events, in ONE
1646
+ * transaction: how many it topped up, how many it took retired keys from (#2082), and
1647
+ * whether the scope is done.
1648
+ */
1649
+ async topUpEntityGrantShapes(tenantId, scopeId, shapes, limit) {
1650
+ return this.queue.enqueue(() => this.revision.transactionSync(() => topUpEntityGrantShapes(this.switchSql(), {
1651
+ tenantId,
1652
+ scopeId,
1653
+ shapes,
1654
+ now: new Date().toISOString(),
1655
+ limit,
1656
+ mintEventId: (ms) => this.mintEventId(ms),
1657
+ version: this.env.SUBSTRAT_VERSION_ID ?? null,
1658
+ })));
1659
+ }
1607
1660
  /**
1608
1661
  * The dispatch capability's admission (#726 remedy B): this delivery may read the
1609
1662
  * attachments of the entity its own spine row names, and no others.
@@ -1733,7 +1786,7 @@ export function defineScopeDO(modules, bareOps) {
1733
1786
  // Legacy path, byte-for-byte what it was: rewrapped so a non-plain error (a
1734
1787
  // ZodError, whose `message` is a getter) still arrives with its message.
1735
1788
  try {
1736
- return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession, verticalCaller, systemDoorInstance);
1789
+ return await this.#invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession, verticalCaller, systemDoorInstance);
1737
1790
  }
1738
1791
  catch (err) {
1739
1792
  if (err instanceof SystemDoorMovedError)
@@ -1742,7 +1795,7 @@ export function defineScopeDO(modules, bareOps) {
1742
1795
  }
1743
1796
  }
1744
1797
  try {
1745
- return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession, verticalCaller, systemDoorInstance);
1798
+ return await this.#invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession, verticalCaller, systemDoorInstance);
1746
1799
  }
1747
1800
  catch (err) {
1748
1801
  // #1834: the pin missed — an answer, never a failure an operation could have produced.
@@ -1754,8 +1807,14 @@ export function defineScopeDO(modules, bareOps) {
1754
1807
  return { result: undefined, platformRequests: 0, failure: toWireFailure(err) };
1755
1808
  }
1756
1809
  }
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,
1810
+ /**
1811
+ * The operation path itself. Throws; `invoke` decides how that reaches the caller.
1812
+ *
1813
+ * `#`-private, not merely `private`: workerd's RPC exposes every method on the prototype,
1814
+ * and this one takes `purge` (#119), which only `runPurgeSweep` may set. A `#` method is not
1815
+ * on the prototype, so no stub can reach it.
1816
+ */
1817
+ async #invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions,
1759
1818
  /** K-42: the session, resolved coordinator-side. See `invoke` above. */
1760
1819
  impersonation,
1761
1820
  /** #1672: a capability session's hash. See `invoke` above. */
@@ -1763,7 +1822,9 @@ export function defineScopeDO(modules, bareOps) {
1763
1822
  /** #1706: the calling vertical, as the platform named it. See `invoke` above. */
1764
1823
  verticalCaller,
1765
1824
  /** #1834: the instance the system door's gate read. See `invoke` above. */
1766
- systemDoorInstance) {
1825
+ systemDoorInstance,
1826
+ /** #119: set only by `runPurgeSweep` on its own invokes — never an RPC argument. */
1827
+ purge) {
1767
1828
  await this.ensureMigrations();
1768
1829
  const handler = this.operations.get(operation);
1769
1830
  // `not_found`, not a bare throw (#113): every vertical hand-matched this message
@@ -1798,6 +1859,8 @@ export function defineScopeDO(modules, bareOps) {
1798
1859
  // declaration. Outside the queue and outside the transaction: a malformed
1799
1860
  // call takes no turn and opens nothing. Guards read the parsed input too,
1800
1861
  // so a K-17 pre-condition sees what the handler will.
1862
+ // #119: purge authority is never an option a caller supplies.
1863
+ assertNoCallerPurge(invokeOptions);
1801
1864
  const declaredInput = this.operationInput.get(operation);
1802
1865
  const parsed = declaredInput ? declaredInput.parse(input) : input;
1803
1866
  // #129. Refused rather than ignored, for the reason the coordinator refuses an
@@ -1924,8 +1987,16 @@ export function defineScopeDO(modules, bareOps) {
1924
1987
  // commit together, or a throw (from either) rolls domain writes AND
1925
1988
  // emitted events back as one — verified across `await` in workerd.
1926
1989
  try {
1990
+ // #119: a module's purge-only keys are withheld from its system principal on every
1991
+ // call but the purge sweep's own invoke of the purge operation.
1992
+ const target = this.operationTarget.get(operation);
1993
+ // #119: purge authority is the system door's, and only `runPurgeSweep` sets `purge`.
1994
+ const purging = purge === true && systemDoor !== undefined;
1995
+ const withheld = systemDoor
1996
+ ? withheldKeysFor(this.purgeOnlyKeys.get(systemDoor.moduleId), target, purging)
1997
+ : undefined;
1927
1998
  await this.revision.transaction(async () => {
1928
- const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemDoor, signals, impersonation, operation, capabilityId, minted, undefined, peerSubject);
1999
+ const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemDoor, signals, impersonation, operation, capabilityId, minted, undefined, peerSubject, undefined, withheld);
1929
2000
  // #116: a retry is answered from the recording, and nothing else runs
1930
2001
  // — not the guards, not the handler, not the permission check inside
1931
2002
  // it. Keyed by SUBJECT, so a caller only ever reaches its own
@@ -1952,6 +2023,9 @@ export function defineScopeDO(modules, bareOps) {
1952
2023
  const seen = guardedRef && invokeOptions?.ifMatch !== undefined
1953
2024
  ? this.versionAt(guardedRef)
1954
2025
  : undefined;
2026
+ // #119: the host's trash refusal, before the guards and the handler — the pure
2027
+ // adapter's, through the same kernel function.
2028
+ 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
2029
  await this.runGuards(operation, ctx, parsed);
1956
2030
  result = await handler(ctx, parsed);
1957
2031
  if (guardedRef && invokeOptions?.ifMatch !== undefined) {
@@ -2148,12 +2222,57 @@ export function defineScopeDO(modules, bareOps) {
2148
2222
  if (withinHeader !== null && !within) {
2149
2223
  return new Response('live reads: unreadable within narrowing', { status: 500 });
2150
2224
  }
2225
+ // The same for an expiry: dropping it would keep the socket open past its session.
2226
+ const expiresHeader = request.headers.get(LIVE_EXPIRES_HEADER);
2227
+ const expiresAt = expiresHeader === null ? undefined : liveInstant(expiresHeader);
2228
+ if (expiresHeader !== null && !expiresAt) {
2229
+ return new Response('live reads: unreadable expiry', { status: 500 });
2230
+ }
2151
2231
  // A subscriber arriving before the scope's migrations have run would be told
2152
2232
  // about events against a schema it cannot read back through. Same gate every
2153
2233
  // other entry point takes, for the same reason.
2154
2234
  await this.ensureMigrations();
2235
+ // `.parse`, not a cast: these three arrived as header strings, and the branded
2236
+ // ids are what every check downstream is keyed on. A malformed one would
2237
+ // otherwise be carried all the way to a `ctx.check` that quietly matches nothing —
2238
+ // which reads as "this subscriber may see nothing" and is indistinguishable from
2239
+ // a correct denial. Refused here instead, where it is still one subscriber's
2240
+ // problem. Throwing is right: the coordinator built this request.
2241
+ const subscriber = {
2242
+ principal: principalId.parse(principal),
2243
+ tenantId: tenantIdOf.parse(tenantId),
2244
+ scopeId: scopeIdOf.parse(scopeId),
2245
+ };
2246
+ // A checked root (#938) is gated here as well as on every pass: a subscriber who may
2247
+ // not watch it gets no socket at all, so its client meets a refusal rather than a
2248
+ // feed that would close on the first thing it had to say. A session already over is
2249
+ // refused the same way.
2250
+ const expired = expiresAt !== undefined && expiresAt <= new Date().toISOString();
2251
+ if (expired ||
2252
+ (within?.checked !== undefined && !(await this.mayWatchRoot(() => this.liveContext(subscriber), within)))) {
2253
+ return new Response('live reads: the subscriber may not watch this root', {
2254
+ status: 403,
2255
+ headers: { [LIVE_MODE_HEADER]: 'forbidden' },
2256
+ });
2257
+ }
2155
2258
  const pair = new WebSocketPair();
2156
2259
  const [client, server] = Object.values(pair);
2260
+ /**
2261
+ * At most `LIVE_SOCKETS_PER_PRINCIPAL` sockets per principal on this scope (#938):
2262
+ * each is work on every post-commit pass. The extra one is accepted and closed at
2263
+ * once with `4429`, rather than refused with a status, because a browser never sees
2264
+ * a failed handshake's status — only a close code, which the client reads as "poll
2265
+ * and stop asking". Not hibernated, so it never joins the roster the fan-out walks.
2266
+ *
2267
+ * Counted over the sockets still live: one whose session has ended is closed here
2268
+ * first, so sockets that expired on an idle scope never hold a fresh session out.
2269
+ */
2270
+ const held = this.reapExpiredLive().filter((s) => s.principal === subscriber.principal).length;
2271
+ if (held >= LIVE_SOCKETS_PER_PRINCIPAL) {
2272
+ server.accept();
2273
+ server.close(LIVE_CLOSE.tooMany, 'too many live subscriptions for this principal; poll instead');
2274
+ return new Response(null, { status: 101, webSocket: client });
2275
+ }
2157
2276
  // HIBERNATABLE, not `server.accept()`. A scope with a watcher open would
2158
2277
  // otherwise be pinned in memory for as long as somebody has a tab open, which is
2159
2278
  // the cost model inverted: a support desk being WATCHED is the normal state.
@@ -2161,21 +2280,94 @@ export function defineScopeDO(modules, bareOps) {
2161
2280
  // eviction, so the socket would stay open and silently stop receiving, which is
2162
2281
  // indistinguishable from a quiet scope.
2163
2282
  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
2283
  server.serializeAttachment({
2171
- principal: principalId.parse(principal),
2172
- tenantId: tenantIdOf.parse(tenantId),
2173
- scopeId: scopeIdOf.parse(scopeId),
2284
+ ...subscriber,
2174
2285
  since: new Date().toISOString(),
2175
2286
  ...(within ? { within } : {}),
2287
+ ...(expiresAt ? { expiresAt } : {}),
2176
2288
  });
2289
+ if (expiresAt)
2290
+ await this.armLiveExpiry();
2177
2291
  return new Response(null, { status: 101, webSocket: client });
2178
2292
  }
2293
+ /**
2294
+ * Close every live socket whose session has ended (`1008`), and return the
2295
+ * subscriptions still open (#938). Reads only the sockets' own attachments, never the
2296
+ * database, so it is as cheap on a scope nobody writes to as on a busy one.
2297
+ */
2298
+ reapExpiredLive(now = new Date().toISOString()) {
2299
+ const live = [];
2300
+ for (const ws of this.ctx.getWebSockets()) {
2301
+ let s = null;
2302
+ try {
2303
+ s = readSubscription(ws.deserializeAttachment());
2304
+ }
2305
+ catch {
2306
+ s = null;
2307
+ }
2308
+ if (!s)
2309
+ continue;
2310
+ if (s.expiresAt !== undefined && s.expiresAt <= now) {
2311
+ closeSessionEnded(ws);
2312
+ continue;
2313
+ }
2314
+ live.push(s);
2315
+ }
2316
+ return live;
2317
+ }
2318
+ /**
2319
+ * Arm the scope's alarm for the earliest session end among its live sockets (#938), so
2320
+ * an expired socket is closed on a scope nobody writes to — the fan-out closes them
2321
+ * too, but only on a pass, and an idle scope has none.
2322
+ *
2323
+ * Only ever moved EARLIER: an alarm already set before that instant is kept, and the
2324
+ * handler re-arms for whatever is next. Re-arming on a close is not needed: an alarm
2325
+ * that finds the socket already gone simply arms for the next one, or for nothing.
2326
+ *
2327
+ * The live reaper is this DO's only alarm use. A second one must share `alarm()` and
2328
+ * this "earliest wins" rule, not call `setAlarm` on its own. The alarm makes workerd
2329
+ * keep a `_cf_METADATA` table in the scope's SQLite; every table walk here skips
2330
+ * `_cf_*` for that reason (`exportDump`, `importDump`, `introspectTables`).
2331
+ */
2332
+ async armLiveExpiry() {
2333
+ let earliest;
2334
+ for (const s of this.reapExpiredLive()) {
2335
+ if (s.expiresAt !== undefined && (earliest === undefined || s.expiresAt < earliest))
2336
+ earliest = s.expiresAt;
2337
+ }
2338
+ if (earliest === undefined)
2339
+ return;
2340
+ const at = Date.parse(earliest);
2341
+ const current = await this.ctx.storage.getAlarm();
2342
+ if (current === null || current > at)
2343
+ await this.ctx.storage.setAlarm(at);
2344
+ }
2345
+ /** The live reaper's alarm (#938): close what has expired, then arm for what is next. */
2346
+ async alarm() {
2347
+ await this.armLiveExpiry();
2348
+ }
2349
+ /** The context a live subscriber's checks run in: its own principal, under `live.subscribe`. */
2350
+ liveContext(subscriber) {
2351
+ return this.operationContext(subscriber.principal, subscriber.tenantId, subscriber.scopeId, undefined, undefined, undefined, undefined, undefined, 'live.subscribe');
2352
+ }
2353
+ /**
2354
+ * May this subscriber watch its `checkedWithin` root (#938)? Its own `ctx.check` of the
2355
+ * stated key on the root, the same walk a read of the root would make. A check that
2356
+ * throws is a refusal: an outage in the permission path must not become a feed.
2357
+ */
2358
+ async mayWatchRoot(context, within) {
2359
+ try {
2360
+ // Built inside the try: a context that cannot be built is a refusal too.
2361
+ const decision = await context().check(within.checked, {
2362
+ entityType: within.entityType,
2363
+ entityId: within.entityId,
2364
+ });
2365
+ return decision.allowed;
2366
+ }
2367
+ catch {
2368
+ return false;
2369
+ }
2370
+ }
2179
2371
  /**
2180
2372
  * A subscriber said something.
2181
2373
  *
@@ -2195,6 +2387,9 @@ export function defineScopeDO(modules, bareOps) {
2195
2387
  */
2196
2388
  webSocketMessage(ws, message) {
2197
2389
  this.webSocketMessagesHandled++;
2390
+ // Ungated, and safe to be: the reply is the constant `'pong'` and carries no event, no
2391
+ // entity and no time, so it tells the socket nothing it did not already know. Every
2392
+ // frame that is about the scope's data goes out through `fanOutLive`'s gates instead.
2198
2393
  if (message === 'ping')
2199
2394
  ws.send('pong');
2200
2395
  }
@@ -2328,12 +2523,33 @@ export function defineScopeDO(modules, bareOps) {
2328
2523
  * operation has already moved the edge the walk follows.
2329
2524
  */
2330
2525
  const parents = scopeTupleReader(this.sql);
2331
- const now = new Date().toISOString();
2526
+ /**
2527
+ * A checked root's gate, asked once per (principal, key, root) per pass (#938): a
2528
+ * principal holding that root in several tabs is one check, fanned out to each socket.
2529
+ */
2530
+ const rootChecks = new Map();
2332
2531
  const ancestors = new Map();
2333
- const reaches = async (row, root) => {
2532
+ const openEpoch = (now) => ({
2533
+ writes: this.revision.statementsWritten,
2534
+ until: this.authorityLapsesAfter(now),
2535
+ remote: this.permissionSourceIsRemote(),
2536
+ });
2537
+ const holds = (e, now) => e.writes === this.revision.statementsWritten && (e.until === null || now < e.until);
2538
+ let epoch = openEpoch(new Date().toISOString());
2539
+ /** The epoch current at `now`, opening a new one (and forgetting every memo) if not. */
2540
+ const epochAt = (now) => {
2541
+ if (!holds(epoch, now)) {
2542
+ epoch = openEpoch(now);
2543
+ rootChecks.clear();
2544
+ ancestors.clear();
2545
+ }
2546
+ return epoch;
2547
+ };
2548
+ const reaches = async (row, root, now) => {
2334
2549
  let up = ancestors.get(row.id);
2335
2550
  if (!up) {
2336
2551
  // A walk that cannot answer is a frame not sent — the same fail-closed rule as the check.
2552
+ // Walked at the decision's own instant; remembered only while its epoch holds.
2337
2553
  up = ancestorsWithin(parents, { entityType: row.entity_type, entityId: row.entity_id }, now).catch(() => new Set());
2338
2554
  ancestors.set(row.id, up);
2339
2555
  }
@@ -2357,6 +2573,18 @@ export function defineScopeDO(modules, bareOps) {
2357
2573
  // assumption about how the subscription was created.
2358
2574
  if (subscription.tenantId !== tenantId || subscription.scopeId !== scopeId)
2359
2575
  continue;
2576
+ // The session that opened it has ended (#938): closed before anything is sent,
2577
+ // whatever the subscriber's grants still say. Checked on every pass, so it holds for
2578
+ // every kind of subscription, not only the ones with a gate to ask — and asked
2579
+ // again, against a fresh clock, immediately before every send below, because the
2580
+ // walk and the checks between here and there await, and a session can end during
2581
+ // them.
2582
+ const { expiresAt } = subscription;
2583
+ const sessionEnded = () => expiresAt !== undefined && expiresAt <= new Date().toISOString();
2584
+ if (sessionEnded()) {
2585
+ closeSessionEnded(ws);
2586
+ continue;
2587
+ }
2360
2588
  const { within } = subscription;
2361
2589
  // One context per subscriber, not per event: `ctx.check` is the expensive part
2362
2590
  // and the context is only the subject it is evaluated for. Built on first use, so
@@ -2372,57 +2600,115 @@ export function defineScopeDO(modules, bareOps) {
2372
2600
  // The operation name is carried anyway, for the events a fan-out cannot emit
2373
2601
  // but a future reader of this context might.
2374
2602
  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) {
2603
+ const subscriber = subscription;
2604
+ const context = () => (ctx ??= this.liveContext(subscriber));
2605
+ /**
2606
+ * A checked root's gate (#938), asked at most once per (principal, key, root) per
2607
+ * pass (`rootChecks`) and only once a row beneath the root is about to be announced,
2608
+ * so an idle subscription costs no check. A refusal, or a check that throws, closes the socket before anything is
2609
+ * sent: the grant is gone, the principal is, or the root moved out of the grant's
2610
+ * reach. Closed rather than skipped, so the subscription does not sit there asking
2611
+ * on every pass, and the client's reconnect meets the handshake's 403. Held with the
2612
+ * write count it was decided under, and asked again once that has moved.
2613
+ */
2614
+ let rootVerdict;
2615
+ /** What this subscriber is owed for one row, decided at `now` under epoch `e`. */
2616
+ const decide = async (row, e, now) => {
2617
+ // A remote authority's verdict is never reused: see `Epoch`.
2618
+ if (rootVerdict?.epoch !== e || e.remote)
2619
+ rootVerdict = undefined;
2378
2620
  // Narrowing first: it is memoised across sockets, and a row outside the root
2379
2621
  // 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
2622
+ if (within && !(await reaches(row, within, now)))
2623
+ return 'skip';
2624
+ if (within?.checked !== undefined) {
2625
+ if (rootVerdict === undefined) {
2626
+ let check;
2627
+ if (e.remote) {
2628
+ check = this.mayWatchRoot(context, within);
2629
+ }
2630
+ else {
2631
+ const key = `${subscriber.principal}\n${within.checked}\n${within.entityType}:${within.entityId}`;
2632
+ check = rootChecks.get(key);
2633
+ if (!check)
2634
+ rootChecks.set(key, (check = this.mayWatchRoot(context, within)));
2635
+ }
2636
+ rootVerdict = { epoch: e, allowed: await check };
2637
+ }
2638
+ if (!rootVerdict.allowed)
2639
+ return 'revoked';
2640
+ }
2641
+ if (within?.vouched !== undefined || within?.checked !== undefined) {
2642
+ // The root was vouched for or checked, so the walk above was the row filter —
2643
+ // and the subscriber holds no read on this row, so it is told only that
2386
2644
  // something beneath its root changed. Never which row, never how.
2387
- frame = { kind: 'nudge', id: row.id, at: row.occurred_at };
2645
+ return { kind: 'nudge', id: row.id, at: row.occurred_at };
2388
2646
  }
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,
2647
+ // Non-null: `announceable` is exactly the rows whose type is in the map.
2648
+ const permission = this.liveTargets.get(row.entity_type);
2649
+ try {
2650
+ const decision = await context().check(permission, {
2413
2651
  entityType: row.entity_type,
2414
2652
  entityId: row.entity_id,
2415
- at: row.occurred_at,
2416
- };
2417
- }
2418
- try {
2419
- ws.send(JSON.stringify(frame));
2653
+ });
2654
+ if (!decision.allowed)
2655
+ return 'skip';
2420
2656
  }
2421
2657
  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;
2658
+ // A check that cannot answer is a check that refuses. The alternative —
2659
+ // treating an evaluator failure as an allow — turns an outage in the
2660
+ // permission path into a disclosure, which is the one failure mode this
2661
+ // surface must not have.
2662
+ return 'skip';
2663
+ }
2664
+ return {
2665
+ kind: 'change',
2666
+ id: row.id,
2667
+ type: row.type,
2668
+ entityType: row.entity_type,
2669
+ entityId: row.entity_id,
2670
+ at: row.occurred_at,
2671
+ };
2672
+ };
2673
+ rows: for (const row of announceable) {
2674
+ for (let attempt = 0; attempt < LIVE_DECIDE_ATTEMPTS; attempt++) {
2675
+ const decidedAt = new Date().toISOString();
2676
+ const e = epochAt(decidedAt);
2677
+ const verdict = await decide(row, e, decidedAt);
2678
+ // Every gate as close to the send as it can be, with no await between these
2679
+ // reads and the send below — so nothing can land after them and before it.
2680
+ // The store wrote, or something the decision relied on lapsed, while it was being
2681
+ // taken (Codex #2077 r4, r5): decide again, from nothing remembered.
2682
+ if (!holds(e, new Date().toISOString()))
2683
+ continue;
2684
+ if (verdict === 'skip')
2685
+ continue rows;
2686
+ if (verdict === 'revoked') {
2687
+ try {
2688
+ ws.close(LIVE_CLOSE.revoked, 'the subscriber may no longer watch this root');
2689
+ }
2690
+ catch {
2691
+ // Already gone.
2692
+ }
2693
+ break rows;
2694
+ }
2695
+ // The clock is read again here too, after every await above.
2696
+ if (sessionEnded()) {
2697
+ closeSessionEnded(ws);
2698
+ break rows;
2699
+ }
2700
+ try {
2701
+ ws.send(JSON.stringify(verdict));
2702
+ }
2703
+ catch {
2704
+ // The socket went away between `getWebSockets()` and here. Stop writing to
2705
+ // this one and move on; the runtime will deliver `webSocketClose`.
2706
+ break rows;
2707
+ }
2708
+ continue rows;
2425
2709
  }
2710
+ // Still moving after every attempt: this row is not announced, and the client's
2711
+ // poll — the floor under every push — picks it up.
2426
2712
  }
2427
2713
  }
2428
2714
  }
@@ -2858,8 +3144,7 @@ export function defineScopeDO(modules, bareOps) {
2858
3144
  return this.queue.enqueue(() => {
2859
3145
  if (systemSwitchedOff(this.switchSql(), moduleId))
2860
3146
  return false;
2861
- this.sql.exec(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at)
2862
- VALUES (?, ?, ?, ?)`, `system:${moduleId}`, relation, object, expiresAt);
3147
+ writeExplicitTupleIn(this.switchSql(), `system:${moduleId}`, relation, object, { kind: 'replace', expiresAt });
2863
3148
  return true;
2864
3149
  });
2865
3150
  }
@@ -3248,6 +3533,70 @@ export function defineScopeDO(modules, bareOps) {
3248
3533
  await this.ensureMigrations();
3249
3534
  return readCapabilityPage(doSpineSql(this.sql), filter);
3250
3535
  }
3536
+ /**
3537
+ * One pass of a purge horizon's schedule on this scope (#119): the oldest entities due for
3538
+ * purge, each deleted by the module's own `trashed: 'purges'` operation, one call and one
3539
+ * transaction per entity, as `system:<moduleId>`.
3540
+ *
3541
+ * The caller names only WHICH schedule. Everything that decides what is purged is this
3542
+ * object's: the module and the entity come from the registered schedule, the cutoff from this
3543
+ * object's clock and the entity's declared horizon, the ids from its own bin — and each purge
3544
+ * re-checks the cutoff inside its transaction (`refuseTrashedTarget`). Purge authority lives
3545
+ * here and nowhere else: `invoke` takes no purge argument, so a stub holding this namespace
3546
+ * cannot hand the system principal its purge-only key, and calling this early, often or from
3547
+ * outside the sweep can only purge what is genuinely due.
3548
+ *
3549
+ * Pinned like every system-door call (#1834): a missed pin answers `SystemDoorMoved` before
3550
+ * anything is read, and the coordinator's door gates again.
3551
+ */
3552
+ async runPurgeSweep(operation, tenantId,
3553
+ /**
3554
+ * The node the purges' checks and events are stamped with. This object records no scope id of
3555
+ * its own, so it cannot hold this one to anything — and it routes nothing: the sweep runs on
3556
+ * the scope that received the call, whatever this says. A wrong one fails closed, since the
3557
+ * module's grant is seated on this scope's node and not on the one named.
3558
+ */
3559
+ scopeId, systemDoorInstance) {
3560
+ await this.ensureMigrations();
3561
+ const schedule = this.purgeSchedules.get(operation);
3562
+ if (!schedule)
3563
+ throw toRpcError(substratError('not_found', `no purge schedule runs ${operation} on this scope`));
3564
+ try {
3565
+ this.assertSystemDoor(schedule.moduleId, systemDoorInstance);
3566
+ }
3567
+ catch (err) {
3568
+ if (err instanceof SystemDoorMovedError)
3569
+ return SYSTEM_DOOR_MOVED;
3570
+ throw toRpcError(err);
3571
+ }
3572
+ await this.pruneContinuationPositions();
3573
+ // #119: the gate the coordinator applies before any schedule fires, applied here from what
3574
+ // this scope records of it (`purgeGateFacts`). A foreign tenant throws.
3575
+ const held = purgeHeldBy(this.purgeGateFacts(schedule.moduleId, tenantId));
3576
+ if (held !== null)
3577
+ return heldPurgePass(held);
3578
+ const due = purgeDueOf(doSpineSql(this.sql), this.statePlans, this.operationTarget, operation, schedule.entityType, new Date().toISOString(), PURGE_BATCH);
3579
+ return runPurgePass(operation, due, async (entityId) => {
3580
+ await this.#invokeOrThrow(operation, { [due.idFrom]: entityId }, schedule.moduleId, tenantId, scopeId, undefined, undefined, schedule.moduleId, { invocationId: ulid() }, undefined, undefined, undefined, systemDoorInstance, true);
3581
+ }, (write) => write(doSpineSql(this.sql)));
3582
+ }
3583
+ /**
3584
+ * #119: the purge gate's facts (`PurgeGateFacts`), from this scope's own storage: the module's
3585
+ * switch, the lifecycle the platform delivered (#1713), the copy classification (#2009) and the
3586
+ * tenant receipt (`tenantVerdict` — `unknown` passes, as at every door). A directory-backed
3587
+ * deployment records no receipt and delivers neither a lifecycle nor a copy classification here,
3588
+ * so on one those read clear: this object enforces the kill switch, and the coordinator's gate
3589
+ * is the authority for tenant, lifecycle, copy and the rewind hold.
3590
+ */
3591
+ purgeGateFacts(moduleId, tenantId) {
3592
+ const now = new Date().toISOString();
3593
+ return {
3594
+ switched: systemScheduleState(this.switchSql(), moduleId, now),
3595
+ lifecycle: lifecycleRefusal(readLifecycle(this.switchSql())),
3596
+ copy: this.isCopy(),
3597
+ foreignTenant: this.tenantVerdict(tenantId).verdict === 'foreign',
3598
+ };
3599
+ }
3251
3600
  /**
3252
3601
  * The last time a schedule's operation ran on this scope (#383), or null.
3253
3602
  *
@@ -3588,18 +3937,55 @@ export function defineScopeDO(modules, bareOps) {
3588
3937
  this.migrationPromise = this.applyPendingMigrations();
3589
3938
  return this.migrationPromise;
3590
3939
  }
3940
+ /** Every registered module's derivation plans — what `repairDerivedObjects` and its checks read. */
3941
+ derivedPlans() {
3942
+ return { state: this.statePlans, lists: this.listPlans, search: this.searchPlans };
3943
+ }
3944
+ /** A multi-statement script on this DO's own handle, one statement per exec. */
3945
+ runScript(ddl) {
3946
+ for (const stmt of executableSqlStatements(ddl))
3947
+ this.sql.exec(stmt);
3948
+ }
3949
+ /**
3950
+ * #2090: the repair a pass with nothing pending runs, in a transaction of its own. A lost
3951
+ * state column fails the scope closed like a failed migration, named by the journaled
3952
+ * migration that added the column — the pure host's `repairDerived`.
3953
+ */
3954
+ async repairDerived() {
3955
+ try {
3956
+ await this.revision.transaction(async () => {
3957
+ this.revisionSuspended = true;
3958
+ try {
3959
+ repairDerivedObjects(doSpineSql(this.sql), (ddl) => this.runScript(ddl), this.derivedPlans(), {
3960
+ after: 'an applied migration',
3961
+ });
3962
+ }
3963
+ finally {
3964
+ this.revisionSuspended = false;
3965
+ }
3966
+ });
3967
+ }
3968
+ catch (err) {
3969
+ const version = err instanceof StateColumnLost ? err.migration : 'kernel@derived-objects';
3970
+ this.lastFailure = { version, error: err.message };
3971
+ throw migrationFailedError(version, err.message);
3972
+ }
3973
+ }
3591
3974
  /** Resolves true if this call applied at least one migration. */
3592
3975
  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
- }
3976
+ const { pending, diverged } = await planMigrations(this.modules.values(), this.applied);
3977
+ if (diverged) {
3978
+ // Recorded as a failed migration is, so the coordinator projects it the same way.
3979
+ this.lastFailure = diverged;
3980
+ throw migrationFailedError(diverged.version, diverged.error);
3600
3981
  }
3601
- if (pending.length === 0)
3982
+ if (pending.length === 0) {
3983
+ // #2090: once per pass — so once per wake, and on every `retryMigrations` — check and
3984
+ // repair what the kernel derived onto the tables. Their migrations are journaled and will
3985
+ // never run again, so this is what reaches a scope stripped before the pass checked.
3986
+ await this.queue.enqueue(async () => this.repairDerived());
3602
3987
  return false;
3988
+ }
3603
3989
  this.migrationRuns += 1;
3604
3990
  await this.queue.enqueue(async () => {
3605
3991
  // #286: bookmark the instant before an UPGRADE migrates live data — the
@@ -3622,34 +4008,53 @@ export function defineScopeDO(modules, bareOps) {
3622
4008
  // (#278) remains the fallback rewind point.
3623
4009
  }
3624
4010
  }
3625
- for (const { moduleId, migration } of pending) {
4011
+ for (const [i, { moduleId, migration, digest, authored }] of pending.entries()) {
3626
4012
  const key = `${moduleId}@${migration.version}`;
3627
4013
  if (this.applied.has(key))
3628
4014
  continue;
4015
+ let recorded = digest;
3629
4016
  try {
3630
4017
  await this.revision.transaction(async () => {
3631
4018
  const already = this.sql
3632
- .exec('SELECT 1 FROM _substrat_migrations WHERE module_id = ? AND version = ?', moduleId, migration.version)
4019
+ .exec('SELECT sql_digest FROM _substrat_migrations WHERE module_id = ? AND version = ?', moduleId, migration.version)
3633
4020
  .toArray()[0];
3634
- if (!already) {
4021
+ if (already) {
4022
+ // Applied since this pass read the journal: held to the same digest rule.
4023
+ const error = migrationDivergence(already.sql_digest, digest, authored);
4024
+ if (error)
4025
+ throw new Error(error);
4026
+ recorded = already.sql_digest;
4027
+ }
4028
+ else {
3635
4029
  const started = performance.now();
3636
4030
  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}`);
4031
+ // #1898, #2066: a migration runs on this DO's own handle, not `ctx.sql`, so the
4032
+ // spine rules a migration is held to are applied here.
4033
+ assertMigrationSql(migration.sql, { key, digest, authored });
4034
+ // #2068: the ownership ledger is the kernel's — no migration may name it, not even to
4035
+ // read it (the spine rules above allow reads).
4036
+ assertMigrationLeavesLedgerAlone(migration.sql, `migration ${key}`);
3640
4037
  // #1722: not counted per statement, so `total_changes()` measures the migration
3641
4038
  // alone. The journal row below is a write, and advances the revision once.
4039
+ // #2068: run one statement at a time, keeping the table set either side of each.
4040
+ let steps;
3642
4041
  this.revisionSuspended = true;
3643
4042
  try {
3644
- for (const stmt of splitSqlStatements(migration.sql)) {
4043
+ steps = runMigrationStatements(doSpineSql(this.sql), migration.sql, (stmt) => {
3645
4044
  this.sql.exec(stmt);
3646
- }
4045
+ });
4046
+ // #2090: the state columns this migration must have left, and after the last of
4047
+ // the pass, the triggers and indexes a create-copy-rename rebuild dropped.
4048
+ const last = i === pending.length - 1;
4049
+ afterMigration(doSpineSql(this.sql), (ddl) => this.runScript(ddl), this.derivedPlans(), key, last);
3647
4050
  }
3648
4051
  finally {
3649
4052
  this.revisionSuspended = false;
3650
4053
  }
3651
4054
  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);
4055
+ // After the count: `rows_changed` is the migration's own, never the kernel's bookkeeping.
4056
+ recordOwnershipSteps(doSpineSql(this.sql), moduleId, steps, new Date().toISOString());
4057
+ 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
4058
  }
3654
4059
  });
3655
4060
  }
@@ -3663,9 +4068,9 @@ export function defineScopeDO(modules, bareOps) {
3663
4068
  // It is not an unhandled rejection: every caller awaits the memoised promise, and
3664
4069
  // the coordinator records the failure it receives (#1898 review).
3665
4070
  this.lastFailure = { version: key, error: err.message };
3666
- throw new Error(`migration failed for ${key} — scope fails closed: ${err.message}`);
4071
+ throw migrationFailedError(key, err.message);
3667
4072
  }
3668
- this.applied.add(key);
4073
+ this.applied.set(key, recorded);
3669
4074
  }
3670
4075
  });
3671
4076
  return true;
@@ -3904,6 +4309,30 @@ export function defineScopeDO(modules, bareOps) {
3904
4309
  attempts = attempts + 1, settled_at = ?
3905
4310
  WHERE id = ? AND status = 'pending'`, status, result, lastError, lastFailure, status === 'pending' ? null : new Date().toISOString(), id);
3906
4311
  }
4312
+ /**
4313
+ * A settle that writes an outcome event (#2102): the kernel's one settle — the same
4314
+ * compare-and-set as the verb above — and the event in its transaction, written only when
4315
+ * this settle moved the row out of `pending`. Then the event reaches this scope's consumers,
4316
+ * as any committed write's does.
4317
+ *
4318
+ * A verb of its own rather than an argument on the one above, so a settle carrying no event
4319
+ * keeps reaching a DO class older than this change; only the new kind's settle needs it.
4320
+ */
4321
+ async settlePlatformRequestWithEvent(tenantId, scopeId, id, outcome) {
4322
+ await this.ensureMigrations();
4323
+ return this.queue.enqueue(async () => {
4324
+ const liveSince = this.liveHighWaterMark();
4325
+ this.revision.transactionSync(() => settlePlatformRequestIn(this.switchSql(), id, outcome, {
4326
+ tenantId,
4327
+ scopeId,
4328
+ now: new Date().toISOString(),
4329
+ mintEventId: (ms) => this.mintEventId(ms),
4330
+ version: this.env.SUBSTRAT_VERSION_ID ?? null,
4331
+ }));
4332
+ // #1525: null — a settle is the platform's call, not one a vertical's caller made.
4333
+ await this.settleCommitted(tenantId, scopeId, liveSince, null);
4334
+ });
4335
+ }
3907
4336
  /**
3908
4337
  * Turn one connector delivery into a `connector:<provider>` platform intent (#574
3909
4338
  * phase 3) — the CP-less host's substitute for running the handler it cannot run.
@@ -3997,7 +4426,7 @@ export function defineScopeDO(modules, bareOps) {
3997
4426
  /** Every table in this scope's DB, with row counts; spine/internal tables flagged. */
3998
4427
  introspectTables() {
3999
4428
  const names = this.sql
4000
- .exec(`SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name`)
4429
+ .exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB '_cf_*' ORDER BY name`)
4001
4430
  .toArray().map((r) => r.name);
4002
4431
  return names.map((name) => ({
4003
4432
  name,
@@ -4021,7 +4450,7 @@ export function defineScopeDO(modules, bareOps) {
4021
4450
  }
4022
4451
  tablePage(table, limit, offset) {
4023
4452
  const known = new Set(this.sql
4024
- .exec(`SELECT name FROM sqlite_master WHERE type = 'table'`)
4453
+ .exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB '_cf_*'`)
4025
4454
  .toArray().map((r) => r.name));
4026
4455
  if (!known.has(table))
4027
4456
  throw substratError('not_found', `unknown table '${table}'`);
@@ -4059,6 +4488,7 @@ export function defineScopeDO(modules, bareOps) {
4059
4488
  }
4060
4489
  async readOnlyQuery(sql) {
4061
4490
  const stmt = assertReadOnlyQuery(sql);
4491
+ assertNoWorkerdStorage(stmt);
4062
4492
  let result;
4063
4493
  const rollback = new Error('read-only console rollback');
4064
4494
  try {
@@ -4136,7 +4566,7 @@ export function defineScopeDO(modules, bareOps) {
4136
4566
  // carries the rows, never the index over them.
4137
4567
  const defs = this.sql
4138
4568
  .exec(`SELECT name, sql FROM sqlite_master
4139
- WHERE type = 'table' AND name NOT GLOB 'sqlite_*' AND sql IS NOT NULL
4569
+ WHERE type = 'table' AND name NOT GLOB 'sqlite_*' AND name NOT GLOB '_cf_*' AND sql IS NOT NULL
4140
4570
  ORDER BY name`)
4141
4571
  .toArray().filter(({ name }) => !isSearchIndexTable(name));
4142
4572
  return defs.map(({ name, sql }) => {
@@ -4199,6 +4629,8 @@ export function defineScopeDO(modules, bareOps) {
4199
4629
  // #1763: rows written before these fields keep NULL, meaning unrecorded.
4200
4630
  'ALTER TABLE _substrat_migrations ADD COLUMN duration_ms INTEGER',
4201
4631
  'ALTER TABLE _substrat_migrations ADD COLUMN rows_changed INTEGER',
4632
+ // #2066: the rows already there get the legacy mark below, never a digest.
4633
+ 'ALTER TABLE _substrat_migrations ADD COLUMN sql_digest TEXT',
4202
4634
  'ALTER TABLE _substrat_tuples ADD COLUMN revoked_at TEXT',
4203
4635
  // #1632: legacy runs retain an unknown subject; no content-based backfill.
4204
4636
  'ALTER TABLE _substrat_job_runs ADD COLUMN subject_id TEXT',
@@ -4255,6 +4687,9 @@ export function defineScopeDO(modules, bareOps) {
4255
4687
  // every legacy row — a past run's id cannot be recovered afterwards, exactly
4256
4688
  // as the other #1525 columns above argued.
4257
4689
  'ALTER TABLE _substrat_schedule_state ADD COLUMN invocation_id TEXT',
4690
+ // #2096: a purge horizon's lap in progress, on a scope DO built before the column. NULL is
4691
+ // the start of a lap — the honest reading of every row already there.
4692
+ 'ALTER TABLE _substrat_schedule_state ADD COLUMN purge_cursor TEXT',
4258
4693
  // #2009: the copy classification on a scope DO built before it (NULL reads as a copy;
4259
4694
  // see `COPY_ORIGIN_DDL`).
4260
4695
  'ALTER TABLE _substrat_copy_origin ADD COLUMN is_copy INTEGER',
@@ -4280,6 +4715,9 @@ export function defineScopeDO(modules, bareOps) {
4280
4715
  // boot. `lint:spine-ddl` compares KERNEL_DDL's indexes only, so this one is held to
4281
4716
  // both adapters by the query-plan test rather than by that gate.
4282
4717
  this.sql.exec('CREATE INDEX IF NOT EXISTS _substrat_outbox_invocation ON _substrat_outbox (invocation_id, id)');
4718
+ this.sql.exec(GRANT_CHILDREN_INDEX_DDL);
4719
+ // #2066: what they ran was never measured. KERNEL_DDL's fence keeps any other NULL out.
4720
+ this.sql.exec(MIGRATION_DIGEST_MARK_LEGACY);
4283
4721
  this.ensureScheduleStateKind();
4284
4722
  this.ensureRefusalsAdmitGuards();
4285
4723
  }
@@ -4320,7 +4758,7 @@ export function defineScopeDO(modules, bareOps) {
4320
4758
  // synchronous, which is the one case the sync API is for (it commits at the
4321
4759
  // first await, and there is none). It also has to be sync because the caller is.
4322
4760
  this.revision.transactionSync(() => {
4323
- for (const stmt of splitSqlStatements(script))
4761
+ for (const stmt of executableSqlStatements(script))
4324
4762
  this.sql.exec(stmt);
4325
4763
  });
4326
4764
  }
@@ -4397,7 +4835,9 @@ export function defineScopeDO(modules, bareOps) {
4397
4835
  this.sql.exec('PRAGMA defer_foreign_keys = ON');
4398
4836
  // Real tables only; `sqlite_*` internals are auto-managed and un-droppable.
4399
4837
  const existing = this.sql
4400
- .exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB 'sqlite_*'`)
4838
+ // Nor workerd's own `_cf_*` (the live reaper's alarm keeps `_cf_METADATA`), which
4839
+ // it refuses to drop.
4840
+ .exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB 'sqlite_*' AND name NOT GLOB '_cf_*'`)
4401
4841
  .toArray();
4402
4842
  // Search index tables are left alone here and rebuilt below (#827): dropping a
4403
4843
  // shadow table directly is an error, and `sqlite_master` order would reach one
@@ -4412,9 +4852,12 @@ export function defineScopeDO(modules, bareOps) {
4412
4852
  // decide how this DO's permission checks match. Every `_substrat_*` table is built
4413
4853
  // from KERNEL_DDL instead, and the dump contributes only rows, by column name
4414
4854
  // (`spineRowsInsert`), a missing column taking the kernel's default.
4855
+ // Comment-blanked (#2068): a dump records `sqlite_master.sql` verbatim, comments included,
4856
+ // and replaying it raw would store them again — and a later DROP COLUMN of the last
4857
+ // column fails on workerd. The validated statement is otherwise executed exactly as is.
4415
4858
  for (const t of replayable)
4416
4859
  if (!isSpineTable(t.name))
4417
- this.sql.exec(t.ddl);
4860
+ this.sql.exec(blankSqlComments(t.ddl));
4418
4861
  // KERNEL_DDL also builds what the dump did not carry (#321). A dump captured from a
4419
4862
  // WORLD that stores some `_substrat_*` tables ELSEWHERE carries only a subset — an
4420
4863
  // `@substrat-run/adapter-sqlite` scope file keeps `_substrat_roles` /
@@ -4424,11 +4867,12 @@ export function defineScopeDO(modules, bareOps) {
4424
4867
  // restore's repair leg (host.projectRolesLocal) — the spine's job is only to exist so
4425
4868
  // the checker can read it. The column pass follows, for the one outbox index KERNEL_DDL
4426
4869
  // leaves to it.
4427
- for (const stmt of splitSqlStatements(KERNEL_DDL))
4870
+ for (const stmt of executableSqlStatements(KERNEL_DDL))
4428
4871
  this.sql.exec(stmt);
4429
4872
  this.applySpineColumnAdditions();
4430
4873
  const columnsOf = (name) => doBuiltColumnsOf(this.sql, name);
4431
4874
  assertSpineTablesBuilt(replayable.map((t) => t.name), columnsOf);
4875
+ assertJournalDumpCoherent(replayable);
4432
4876
  // A spine column this kernel does not know (a dump from a newer one) is kept, as a plain
4433
4877
  // untyped column the checker never reads.
4434
4878
  for (const t of replayable) {
@@ -4479,6 +4923,17 @@ export function defineScopeDO(modules, bareOps) {
4479
4923
  if (destScopeId) {
4480
4924
  this.rewriteScopeTuples(destScopeId, sourceScopeId && { scopeId: sourceScopeId, exact }, new Date().toISOString());
4481
4925
  }
4926
+ // #827 / #119 / #811: the search triggers, the archive/trash guard triggers, the purge
4927
+ // sweep's index and the derived list indexes went with the dropped tables, and the search index points at rows
4928
+ // that are gone. Repaired, and the search index rebuilt, AFTER the rows — which may
4929
+ // legitimately arrive archived or trashed — as the dump's own journal says they were
4930
+ // derived (#2090). Inside the replay's transaction: a dump whose journal owes a table a
4931
+ // state column it does not carry rolls the whole load back. A plan whose table this dump
4932
+ // did not carry is skipped: a restore must not invent one.
4933
+ repairDerivedObjects(doSpineSql(this.sql), (ddl) => this.runScript(ddl), this.derivedPlans(), {
4934
+ after: 'the restored dump',
4935
+ absentTable: 'skip',
4936
+ });
4482
4937
  // #1742: the recorded-off modules go back off INSIDE the replay's transaction, with
4483
4938
  // the spine and the re-point. A switch that throws rolls the whole restore back, so
4484
4939
  // the dump's grants never commit live without the switch that should cover them.
@@ -4491,37 +4946,6 @@ export function defineScopeDO(modules, bareOps) {
4491
4946
  }
4492
4947
  });
4493
4948
  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
4949
  // #1335 / #1686: the outbox arrived with the dump, so this DO's event ids resume above it,
4526
4950
  // as on a wake. A copy's own events then sort above every copied one, which is what
4527
4951
  // `emittedHere()` relies on. A dump whose top id is no ULID leaves the floor where it was.
@@ -4536,12 +4960,7 @@ export function defineScopeDO(modules, bareOps) {
4536
4960
  }
4537
4961
  // The frontier arrived with the dump — refresh the in-memory applied set so a
4538
4962
  // 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
- }
4963
+ this.applied = readAppliedMigrations(this.sql);
4545
4964
  // …and forget that this INSTANCE ever ran a migration pass (#1589). Refreshing
4546
4965
  // the set above is not enough on its own: `ensureMigrations` memoises its
4547
4966
  // promise, so a warm DO answers "already migrated" from the cache and never
@@ -4565,6 +4984,12 @@ export function defineScopeDO(modules, bareOps) {
4565
4984
  // is complete applies nothing, so there is nothing to report either way.
4566
4985
  this.migrationPromise = undefined;
4567
4986
  this.lastFailure = null;
4987
+ // The KV key never travels in a SQL dump. A load into this same DO would
4988
+ // otherwise retain its old epoch, so explicitly invalidate all old tokens.
4989
+ await this.ctx.storage.delete('continuation:keys');
4990
+ const oldPositions = await this.ctx.storage.list({ prefix: 'continuation:position:' });
4991
+ for (const name of oldPositions.keys())
4992
+ await this.ctx.storage.delete(name);
4568
4993
  return switched;
4569
4994
  }
4570
4995
  /**
@@ -4801,9 +5226,41 @@ export function defineScopeDO(modules, bareOps) {
4801
5226
  * outranks their Article 17 right.
4802
5227
  */
4803
5228
  async redactSubject(subjectId) {
5229
+ // #2068: the module half reaches the scope's own tables, so they have to exist.
5230
+ await this.ensureMigrations();
4804
5231
  // One instant for the whole erasure — the intent tombstones must not disagree with
4805
5232
  // each other about when a person was erased.
4806
5233
  const at = new Date().toISOString();
5234
+ // ONE transaction (#2068): a module's `onSubjectErased` hook that throws rolls the
5235
+ // whole redaction back, the spine half with it, and the coordinator — which destroys
5236
+ // the key only after this returns — never reaches the key.
5237
+ //
5238
+ // Its refusal is answered as DATA (`toWireFailure`, #113): workerd delivers a throw across
5239
+ // this boundary as its message alone, and a hook's `forbidden` (it reached past its own
5240
+ // tables) is a different answer from an `internal`. In this method's own reply rather than
5241
+ // a `…Reply` sibling, so every coordinator — old ones too — keeps calling one verb: an old
5242
+ // one reads a failure as a reply missing its counts and refuses before the key, which is
5243
+ // the safe reading.
5244
+ try {
5245
+ return this.revision.transactionSync(() => this.redactSubjectInTransaction(subjectId, at));
5246
+ }
5247
+ catch (err) {
5248
+ return { failure: toWireFailure(err) };
5249
+ }
5250
+ }
5251
+ /** The body of `redactSubject`, inside its transaction. */
5252
+ redactSubjectInTransaction(subjectId, at) {
5253
+ // The module half first (#2068): the declared entities, then each hook, with the search
5254
+ // indexes over them under FTS5 secure-delete. The spine half follows in the same transaction.
5255
+ const vertical = eraseSubjectFromModules({
5256
+ sql: doSpineSql(this.sql),
5257
+ plans: this.erasurePlans,
5258
+ searchPlans: this.searchPlans.values(),
5259
+ statefulTables: statefulTablesOf(this.statePlans),
5260
+ subjectId,
5261
+ at,
5262
+ migrationSqlOf: (moduleId, version) => this.modules.get(moduleId)?.migrations.find((m) => m.version === version)?.sql,
5263
+ });
4807
5264
  const doomed = this.sql
4808
5265
  .exec(`SELECT id FROM _substrat_outbox
4809
5266
  WHERE subject_id = ? AND pii_class != 'none' AND payload IS NOT NULL`, subjectId)
@@ -4824,6 +5281,7 @@ export function defineScopeDO(modules, bareOps) {
4824
5281
  // The free-text copies (#1632), and the tombstoned intents the coordinator hands to
4825
5282
  // the directory half. Last, so those ids include every intent tombstoned above.
4826
5283
  ...redactSubjectScopeText(sql, subjectId, at),
5284
+ vertical,
4827
5285
  };
4828
5286
  }
4829
5287
  /**
@@ -5065,7 +5523,9 @@ export function defineScopeDO(modules, bareOps) {
5065
5523
  */
5066
5524
  peerSubject,
5067
5525
  /** #2055: the delivery a consumer's or an import's context runs for; absent otherwise. */
5068
- delivery) {
5526
+ delivery,
5527
+ /** #119: keys this context's checks refuse whatever the subject holds (`purgeOnlyKeysOf`). */
5528
+ withheld) {
5069
5529
  const checker = this.checker;
5070
5530
  const relations = this.relations;
5071
5531
  const searchPlans = this.searchPlans;
@@ -5126,6 +5586,9 @@ export function defineScopeDO(modules, bareOps) {
5126
5586
  // #1642: parsed before the system actor's early return, which never reaches
5127
5587
  // the checker — a cast key would otherwise become that path's proof relation.
5128
5588
  const permission = assertPermissionKey(unparsed);
5589
+ if (withheld?.has(permission)) {
5590
+ return { allowed: false, checked: permission, node: { tenantId, scopeId } };
5591
+ }
5129
5592
  if (systemActor) {
5130
5593
  return {
5131
5594
  allowed: true,
@@ -5206,10 +5669,12 @@ export function defineScopeDO(modules, bareOps) {
5206
5669
  // #1672: a capability's own id stands in so the type holds — it is not a person, and
5207
5670
  // the event actor says what it is instead. Every other door passes its own value.
5208
5671
  principal: capabilityId ? capabilityId : principal,
5672
+ 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
5673
  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()))
5674
+ // #119 / #2090: after runtime DDL, inside this operation's transaction, what the kernel
5675
+ // derived onto any table is repaired — and a lost state column fails the operation.
5676
+ derivesAnything(this.derivedPlans())
5677
+ ? () => afterRuntimeDdl(doSpineSql(sql), (ddl) => this.runScript(ddl), this.derivedPlans())
5213
5678
  : undefined), minted),
5214
5679
  now: () => at,
5215
5680
  // #1746/#1747: the host stamps who and where; module code supplies only the template
@@ -5268,6 +5733,10 @@ export function defineScopeDO(modules, bareOps) {
5268
5733
  return entityVersionOf(sql.exec(q.sql, ...q.params).toArray());
5269
5734
  },
5270
5735
  check: runCheck,
5736
+ grantedEntities: async (unparsed, entityType, options) => {
5737
+ const permission = assertPermissionKey(unparsed);
5738
+ return grantedEntitiesForContext(checker, subject, permission, { tenantId, scopeId }, entityType, options, runCheck, withheld, Boolean(systemActor));
5739
+ },
5271
5740
  canAssign: runCanAssign,
5272
5741
  // #827. Mirror of the pure adapter: the plan comes from registration, the
5273
5742
  // rows from this DO's own SQLite, and the index is maintained by triggers
@@ -5303,15 +5772,14 @@ export function defineScopeDO(modules, bareOps) {
5303
5772
  view: uncheckedView('ctx.page', entityType, params.view),
5304
5773
  });
5305
5774
  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 };
5775
+ // `pageOf`'s rule, and each row's own cursor when asked (#2073).
5776
+ const mint = (row) => cursorOf(row, q.sortColumn, plan.idColumn, q.order, q.view);
5309
5777
  if (!params.total)
5310
- return page;
5778
+ return pageOf(rows, limit, mint, params.rowCursors);
5311
5779
  const counted = sql
5312
5780
  .exec(q.countSql, ...q.countParams)
5313
5781
  .toArray();
5314
- return { ...page, total: counted[0]?.n ?? 0 };
5782
+ return countedPageOf(rows, limit, mint, counted[0]?.n ?? 0, params.rowCursors);
5315
5783
  },
5316
5784
  /**
5317
5785
  * Delegate a permission this caller holds onto one entity — see the
@@ -5329,7 +5797,8 @@ export function defineScopeDO(modules, bareOps) {
5329
5797
  throw new PermissionDenied(`cannot grant '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
5330
5798
  'the caller does not hold it there (a grant delegates, it never elevates)');
5331
5799
  }
5332
- sql.exec(`INSERT OR IGNORE INTO _substrat_tuples (subject, relation, object) VALUES (?, ?, ?)`, `principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`);
5800
+ // #2071: an explicit grant, so it clears a tombstone `revoke` left.
5801
+ writeExplicitTupleIn(this.switchSql(), `principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`, { kind: 'delegated' });
5333
5802
  },
5334
5803
  /**
5335
5804
  * Deliberately NOT the #1856 grammar check `grant` and `link` make: a revoke writes
@@ -5343,7 +5812,10 @@ export function defineScopeDO(modules, bareOps) {
5343
5812
  throw new PermissionDenied(`cannot revoke '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
5344
5813
  'the caller does not hold it there');
5345
5814
  }
5346
- sql.exec(`DELETE FROM _substrat_tuples WHERE subject = ? AND relation = ? AND object = ?`, `principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`);
5815
+ // K-21 (#2071): a tombstone, never a delete — a declared shape's top-up must be
5816
+ // able to tell a key taken back from one never held.
5817
+ const r = delegatedRevokeSql(principal, permission, `${entity.entityType}:${entity.entityId}`, at);
5818
+ sql.exec(r.sql, ...r.params);
5347
5819
  },
5348
5820
  atomic: createAtomic(runSub, { passed, signals }),
5349
5821
  // #1672: mint / revoke / list, written once in the kernel — the pure adapter hands
@@ -5369,6 +5841,7 @@ export function defineScopeDO(modules, bareOps) {
5369
5841
  now: at,
5370
5842
  emit: (event) => writeEvent(event, 'kernel'),
5371
5843
  assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
5844
+ isUnreachableParent: (entity) => isUnreachableParent(doSpineSql(sql), statePlans, entity),
5372
5845
  }),
5373
5846
  // #119: archive and trash — the pure adapter's wiring, over the raw spine seam.
5374
5847
  ...createEntityStateVerbs({
@@ -5385,6 +5858,7 @@ export function defineScopeDO(modules, bareOps) {
5385
5858
  searchPlans,
5386
5859
  statePlans,
5387
5860
  check: runCheck,
5861
+ continuation: (entityType, params) => ctxRef.pageContinuation(`trash:${entityType}`, { ...params, view: 'trashed' }),
5388
5862
  }),
5389
5863
  entitlement: async (key) => {
5390
5864
  const held = await entitlementReader().listEntitlements(tenantId);
@@ -5427,6 +5901,29 @@ export function defineScopeDO(modules, bareOps) {
5427
5901
  .toArray()[0];
5428
5902
  return row?.value === 'local' ? 'local' : 'control-plane';
5429
5903
  }
5904
+ /**
5905
+ * Whether a check here reads the directory over RPC (#938, Codex #2077 r5): the same
5906
+ * choice `controlPlaneReader` makes per call, asked each time a live fan-out pass opens an epoch.
5907
+ */
5908
+ permissionSourceIsRemote() {
5909
+ return this.permissionSource() !== 'local' && Boolean(this.env.CONTROL_PLANE);
5910
+ }
5911
+ /**
5912
+ * The earliest `expires_at` after `now` in any local store a check or the parent walk
5913
+ * reads (#938, Codex #2077 r5), or null when nothing here lapses. Until then, no grant,
5914
+ * tenant tuple, parent edge or entitlement this scope holds can stop authorizing without
5915
+ * a write. Revoked rows are not excluded: an earlier bound only costs a re-check.
5916
+ */
5917
+ authorityLapsesAfter(now) {
5918
+ const row = this.sql
5919
+ .exec(`SELECT MIN(e) AS until FROM (
5920
+ SELECT MIN(expires_at) AS e FROM _substrat_tuples WHERE expires_at > ?
5921
+ UNION ALL SELECT MIN(expires_at) FROM _substrat_tenant_tuples WHERE expires_at > ?
5922
+ UNION ALL SELECT MIN(expires_at) FROM _substrat_entitlements WHERE expires_at > ?
5923
+ )`, now, now, now)
5924
+ .toArray()[0];
5925
+ return row?.until ?? null;
5926
+ }
5430
5927
  /**
5431
5928
  * The checker's tenant-tuple/role reader. Chosen **per call** (the source can
5432
5929
  * flip at runtime, and the checker holds this wrapper for the DO's lifetime):