@substrat-run/adapter-cloudflare 0.138.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, 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, listIndexPlans, moduleMigrations, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, attachmentRecordOfRow, enqueueAttachmentText, reconcileAttachmentText, 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, SWITCH_FENCES_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, createCapabilityVerbs, createEntityEdgeVerbs, 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,
@@ -349,6 +363,9 @@ const KERNEL_DDL = `
349
363
  -- #1686: where a copied scope's data came from, and the event id its own events start above.
350
364
  -- Shared with the other adapter from @substrat-run/kernel; the column comments are there.
351
365
  ${COPY_ORIGIN_DDL}
366
+ -- #119: the kernel's authorization for one archive/trash move, read by the derived update
367
+ -- trigger. Shared with the other adapter from @substrat-run/kernel; the comments are there.
368
+ ${ENTITY_STATE_MOVES_DDL}
352
369
  `;
353
370
  /**
354
371
  * Workers RPC carries a plain `Error`'s MESSAGE faithfully and nothing else. A custom
@@ -417,6 +434,17 @@ class WriteRevision {
417
434
  keeping = false;
418
435
  /** A statement's text is almost always a constant, so its answer is remembered (bounded). */
419
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;
420
448
  sql;
421
449
  constructor(raw, storage, suspended,
422
450
  /** Why this store takes no write at all right now, or null (a `carried_away` copy, #1722). */
@@ -426,7 +454,10 @@ class WriteRevision {
426
454
  this.suspended = suspended;
427
455
  this.refusal = refusal;
428
456
  const exec = (query, ...bindings) => {
429
- if (this.keeping && this.isWrite(query)) {
457
+ const write = this.isWrite(query);
458
+ if (write)
459
+ this.written++;
460
+ if (this.keeping && write) {
430
461
  // Bookkeeping takes the copy-marker insert and the lifecycle delivery (#1713) and nothing
431
462
  // else: any other write here would be one the revision never saw, which is the hole this
432
463
  // class exists to close.
@@ -435,7 +466,7 @@ class WriteRevision {
435
466
  }
436
467
  return raw.exec(query, ...bindings);
437
468
  }
438
- if (!this.suspended() && this.isWrite(query)) {
469
+ if (write && !this.suspended()) {
439
470
  const refused = this.refusal();
440
471
  if (refused)
441
472
  throw substratError('conflict', refused);
@@ -501,6 +532,10 @@ class WriteRevision {
501
532
  this.covered = false;
502
533
  });
503
534
  }
535
+ /** How many write statements this handle has run (see `written`). */
536
+ get statementsWritten() {
537
+ return this.written;
538
+ }
504
539
  isWrite(query) {
505
540
  let known = this.writes.get(query);
506
541
  if (known === undefined) {
@@ -587,8 +622,6 @@ function cellToJson(v) {
587
622
  * trigger: it passed on better-sqlite3 (whose `exec` takes the whole blob) and
588
623
  * failed every scope on the DO host.
589
624
  */
590
- const IS_CREATE_TRIGGER = /^\s*CREATE\s+(TEMP\s+|TEMPORARY\s+)?TRIGGER\b/i;
591
- const ENDS_WITH_END = /\bEND\s*$/i;
592
625
  /**
593
626
  * The kernel's two-method SQL handle (`SwitchSql`) over a Durable Object's storage — what the
594
627
  * shared switch rules (#1666) and the directory's switch record (#1674) run through. One
@@ -602,67 +635,8 @@ export function switchSqlOver(sql) {
602
635
  },
603
636
  };
604
637
  }
605
- export function splitSqlStatements(sql) {
606
- const out = [];
607
- let cur = '';
608
- const n = sql.length;
609
- let i = 0;
610
- while (i < n) {
611
- const c = sql[i];
612
- const c2 = sql[i + 1];
613
- if (c === '-' && c2 === '-') {
614
- while (i < n && sql[i] !== '\n')
615
- i += 1; // line comment → end of line
616
- continue;
617
- }
618
- if (c === '/' && c2 === '*') {
619
- i += 2;
620
- while (i < n && !(sql[i] === '*' && sql[i + 1] === '/'))
621
- i += 1;
622
- i += 2; // block comment → past the closing */
623
- continue;
624
- }
625
- if (c === "'") {
626
- cur += c;
627
- i += 1;
628
- while (i < n) {
629
- cur += sql[i];
630
- if (sql[i] === "'") {
631
- if (sql[i + 1] === "'") {
632
- cur += sql[i + 1]; // '' is an escaped quote, still inside the string
633
- i += 2;
634
- continue;
635
- }
636
- i += 1;
637
- break;
638
- }
639
- i += 1;
640
- }
641
- continue;
642
- }
643
- if (c === ';') {
644
- // Inside a trigger body, a `;` ends an inner statement, not the CREATE.
645
- // `END` is matched as a bare word at the end of what has accumulated —
646
- // a string literal ending in END reads as `…END'`, so the quote keeps it
647
- // from matching, and the string scanner above has already copied it whole.
648
- if (IS_CREATE_TRIGGER.test(cur) && !ENDS_WITH_END.test(cur)) {
649
- cur += c;
650
- i += 1;
651
- continue;
652
- }
653
- if (cur.trim())
654
- out.push(cur.trim());
655
- cur = '';
656
- i += 1;
657
- continue;
658
- }
659
- cur += c;
660
- i += 1;
661
- }
662
- if (cur.trim())
663
- out.push(cur.trim());
664
- return out;
665
- }
638
+ /** The splitter both adapters run migrations through — the kernel's, re-exported for this package's callers. */
639
+ export { splitSqlStatements };
666
640
  /**
667
641
  * #1834: the pin missed. Thrown only by `assertSystemDoor`, and module-private, so no operation can
668
642
  * throw one: the two RPCs a door call reaches turn it into the `SystemDoorMoved` ANSWER, by class
@@ -672,9 +646,51 @@ class SystemDoorMovedError extends Error {
672
646
  }
673
647
  /** #1834: the brand only `assertSystemDoor` sets, so nothing else can make a `SystemDoorPass`. */
674
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
+ }
675
658
  export function defineScopeDO(modules, bareOps) {
676
659
  return class ScopeDO extends DurableObject {
677
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
+ }
678
694
  queue = new OperationQueue();
679
695
  operations = new Map();
680
696
  /**
@@ -685,6 +701,12 @@ export function defineScopeDO(modules, bareOps) {
685
701
  operationInput = new Map();
686
702
  /** #129: name → the entity whose version an `If-Match` is compared against. */
687
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();
688
710
  /** #116: the operations that declared `idempotency: false` — refusals, not participants. */
689
711
  operationIdempotencyOptOut = new Set();
690
712
  modules = new Map();
@@ -708,6 +730,10 @@ export function defineScopeDO(modules, bareOps) {
708
730
  searchPlans = new Map();
709
731
  /** #811: the paged lists modules declare, by entity type. Same one-owner rule. */
710
732
  listPlans = new Map();
733
+ /** #119: entity type → its archive/trash plan, from every registered module. */
734
+ statePlans = new Map();
735
+ /** #2068: each registered module's erasure, in registration order — what `redactSubject` runs. */
736
+ erasurePlans = [];
711
737
  /** entityType → the declared attachment gate (#473): read key + write key (default: read). */
712
738
  attachmentTargets = new Map();
713
739
  /**
@@ -731,7 +757,8 @@ export function defineScopeDO(modules, bareOps) {
731
757
  * underneath rows it already stored.
732
758
  */
733
759
  mintEventId = createUlid();
734
- applied = new Set();
760
+ /** `module@version` → the SQL digest its journal row recorded, null for a row from before #2066. */
761
+ applied = new Map();
735
762
  migrationPromise;
736
763
  /** Latch: the applied count is reported to the directory once per DO instance. */
737
764
  schemaVersionReported = false;
@@ -774,7 +801,7 @@ export function defineScopeDO(modules, bareOps) {
774
801
  // transaction on `ctx.storage` itself (`carried-copy.test.ts` in the kernel holds that).
775
802
  this.revision = new WriteRevision(ctx.storage.sql, ctx.storage, () => this.revisionSuspended, () => (this.carriedAwayCopy ? CARRIED_AWAY_WRITE_REFUSAL : null));
776
803
  this.sql = this.revision.sql;
777
- for (const stmt of splitSqlStatements(KERNEL_DDL)) {
804
+ for (const stmt of executableSqlStatements(KERNEL_DDL)) {
778
805
  this.sql.exec(stmt);
779
806
  }
780
807
  this.applySpineColumnAdditions();
@@ -785,11 +812,7 @@ export function defineScopeDO(modules, bareOps) {
785
812
  for (const [name, handler] of Object.entries(bareOps))
786
813
  this.defineOperation(name, handler);
787
814
  // Which migrations have already run (a warm DO wakes with rows here).
788
- for (const row of this.sql
789
- .exec('SELECT module_id, version FROM _substrat_migrations')
790
- .toArray()) {
791
- this.applied.add(`${row.module_id}@${row.version}`);
792
- }
815
+ this.applied = readAppliedMigrations(this.sql);
793
816
  // #1335: and where this DO's event ids have to resume from. A revived DO would
794
817
  // otherwise start its floor at the wall clock, and a clock that has stepped back
795
818
  // — an NTP correction is small but real — mints underneath rows already stored,
@@ -805,6 +828,15 @@ export function defineScopeDO(modules, bareOps) {
805
828
  // -- module registration (port of SqliteScopeHost.registerModule) ---------
806
829
  registerModule(registration) {
807
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);
808
840
  if (manifest.peers?.length)
809
841
  this.peerSources.push({ peers: manifest.peers });
810
842
  // #827: the FTS indexes `searchables` declares, appended after the module's
@@ -819,8 +851,10 @@ export function defineScopeDO(modules, bareOps) {
819
851
  }
820
852
  this.searchPlans.set(plan.entityType, plan);
821
853
  }
854
+ // #119: the archive/trash plans, refusing a second owner of one entity type.
855
+ addStatePlans(this.statePlans, manifest.id, manifest.entityStates, manifest.permissions);
822
856
  // #811: the list indexes `lists` declares, same placement and same reason.
823
- for (const plan of listIndexPlans(manifest.id, manifest.lists)) {
857
+ for (const plan of listIndexPlans(manifest.id, manifest.lists, manifest.entityStates)) {
824
858
  const existing = this.listPlans.get(plan.entityType);
825
859
  if (existing) {
826
860
  throw new Error(`list: '${plan.entityType}' declares a paged list in both '${existing.moduleId}' and ` +
@@ -834,11 +868,14 @@ export function defineScopeDO(modules, bareOps) {
834
868
  id: manifest.id,
835
869
  // The order the kernel writes once (#1677): authored, then search, then list indexes.
836
870
  migrations: moduleMigrations(registration),
871
+ steps: migrationSteps(registration),
837
872
  consumers: Object.entries(registration.consumers ?? {}).map(([eventType, handler]) => ({
838
873
  eventType,
839
874
  handler,
840
875
  })),
841
876
  });
877
+ if (erasure)
878
+ this.erasurePlans.push(erasure);
842
879
  for (const [name, handler] of Object.entries(registration.predicates ?? {})) {
843
880
  this.predicates.set(name, { module: manifest.id, handler });
844
881
  }
@@ -925,6 +962,9 @@ export function defineScopeDO(modules, bareOps) {
925
962
  if (declaredOptOuts.includes(name) && this.operations.has(name)) {
926
963
  this.operationIdempotencyOptOut.add(name);
927
964
  }
965
+ const target = trashTargets.get(name);
966
+ if (target && this.operations.has(name))
967
+ this.operationTarget.set(name, target);
928
968
  }
929
969
  }
930
970
  defineOperation(name, handler) {
@@ -1306,13 +1346,12 @@ export function defineScopeDO(modules, bareOps) {
1306
1346
  const { hops: _hops, ...fact } = e;
1307
1347
  const event = structuredClone({ ...fact, source });
1308
1348
  const unit = unitOf(imp.moduleId, e.id, e.type);
1309
- this.causedBy = e.id;
1310
- this.unitInvocationId = unit.invocationId;
1311
1349
  try {
1312
1350
  await this.revision.transaction(async () => {
1313
1351
  // As the producer's principal: real checks against the grants this vertical's
1314
1352
  // `peers` gave it; its emits carry `{ vertical, scope }` and what they passed.
1315
- await imp.handler(this.importContext(tenantId, scopeId, peerSubject), event);
1353
+ // What it emits was emitted BECAUSE of the producer's event (#1237).
1354
+ await imp.handler(this.importContext(tenantId, scopeId, peerSubject, { causedBy: e.id, invocationId: unit.invocationId }), event);
1316
1355
  this.sql.exec(`INSERT INTO _substrat_deliveries (event_id, consumer_module, delivered_at, invocation_id)
1317
1356
  VALUES (?, ?, ?, NULL)`, e.id, imp.moduleId, new Date().toISOString());
1318
1357
  });
@@ -1325,10 +1364,6 @@ export function defineScopeDO(modules, bareOps) {
1325
1364
  result.deadLettered += 1;
1326
1365
  lines.write({ ...unit, outcome: 'dead-lettered', error: err });
1327
1366
  }
1328
- finally {
1329
- this.causedBy = null;
1330
- this.unitInvocationId = null;
1331
- }
1332
1367
  }
1333
1368
  if (!ran)
1334
1369
  result.duplicates += 1;
@@ -1345,8 +1380,12 @@ export function defineScopeDO(modules, bareOps) {
1345
1380
  * passes it (#1706). `principal` is a placeholder that the subject never reads, and there is
1346
1381
  * no operation, so the emitted rows' `operation` is NULL, as it is for any consumer.
1347
1382
  */
1348
- importContext(tenantId, scopeId, peerSubject) {
1349
- return this.operationContext(principalId.parse(ulid()), tenantId, scopeId, undefined, undefined, undefined, undefined, undefined, undefined, undefined, [], undefined, peerSubject);
1383
+ /** A module consumer's context: the system override, and the delivery it runs for (#2055). */
1384
+ consumerContext(tenantId, scopeId, moduleId, delivery) {
1385
+ return this.operationContext(this.systemPrincipal, tenantId, scopeId, { system: moduleId }, undefined, undefined, undefined, undefined, undefined, undefined, [], undefined, undefined, delivery);
1386
+ }
1387
+ importContext(tenantId, scopeId, peerSubject, delivery) {
1388
+ return this.operationContext(principalId.parse(ulid()), tenantId, scopeId, undefined, undefined, undefined, undefined, undefined, undefined, undefined, [], undefined, peerSubject, delivery);
1350
1389
  }
1351
1390
  async markEventsDrained(eventIds, at) {
1352
1391
  if (eventIds.length === 0)
@@ -1565,10 +1604,10 @@ export function defineScopeDO(modules, bareOps) {
1565
1604
  * Provisioning does not come through here; it seats with `seatTuple` (#1659).
1566
1605
  */
1567
1606
  async writeTuple(subject, relation, object, expiresAt) {
1568
- await this.queue.enqueue(() => {
1569
- this.sql.exec(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at)
1570
- VALUES (?, ?, ?, ?)`, subject, relation, object, expiresAt);
1571
- });
1607
+ return replyOf(() => this.queue.enqueue(() => {
1608
+ writeExplicitTupleIn(this.switchSql(), subject, relation, object, { kind: 'replace', expiresAt });
1609
+ return null;
1610
+ }));
1572
1611
  }
1573
1612
  /**
1574
1613
  * Provisioning's scope-tuple write (#1659): create the row if it is missing, follow the
@@ -1598,6 +1637,26 @@ export function defineScopeDO(modules, bareOps) {
1598
1637
  return switchOff ? switchRecordedOff(this.switchSql(), switchOff) : [];
1599
1638
  }));
1600
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
+ }
1601
1660
  /**
1602
1661
  * The dispatch capability's admission (#726 remedy B): this delivery may read the
1603
1662
  * attachments of the entity its own spine row names, and no others.
@@ -1727,7 +1786,7 @@ export function defineScopeDO(modules, bareOps) {
1727
1786
  // Legacy path, byte-for-byte what it was: rewrapped so a non-plain error (a
1728
1787
  // ZodError, whose `message` is a getter) still arrives with its message.
1729
1788
  try {
1730
- 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);
1731
1790
  }
1732
1791
  catch (err) {
1733
1792
  if (err instanceof SystemDoorMovedError)
@@ -1736,7 +1795,7 @@ export function defineScopeDO(modules, bareOps) {
1736
1795
  }
1737
1796
  }
1738
1797
  try {
1739
- 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);
1740
1799
  }
1741
1800
  catch (err) {
1742
1801
  // #1834: the pin missed — an answer, never a failure an operation could have produced.
@@ -1748,8 +1807,14 @@ export function defineScopeDO(modules, bareOps) {
1748
1807
  return { result: undefined, platformRequests: 0, failure: toWireFailure(err) };
1749
1808
  }
1750
1809
  }
1751
- /** The operation path itself. Throws; `invoke` decides how that reaches the caller. */
1752
- 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,
1753
1818
  /** K-42: the session, resolved coordinator-side. See `invoke` above. */
1754
1819
  impersonation,
1755
1820
  /** #1672: a capability session's hash. See `invoke` above. */
@@ -1757,7 +1822,9 @@ export function defineScopeDO(modules, bareOps) {
1757
1822
  /** #1706: the calling vertical, as the platform named it. See `invoke` above. */
1758
1823
  verticalCaller,
1759
1824
  /** #1834: the instance the system door's gate read. See `invoke` above. */
1760
- systemDoorInstance) {
1825
+ systemDoorInstance,
1826
+ /** #119: set only by `runPurgeSweep` on its own invokes — never an RPC argument. */
1827
+ purge) {
1761
1828
  await this.ensureMigrations();
1762
1829
  const handler = this.operations.get(operation);
1763
1830
  // `not_found`, not a bare throw (#113): every vertical hand-matched this message
@@ -1792,6 +1859,8 @@ export function defineScopeDO(modules, bareOps) {
1792
1859
  // declaration. Outside the queue and outside the transaction: a malformed
1793
1860
  // call takes no turn and opens nothing. Guards read the parsed input too,
1794
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);
1795
1864
  const declaredInput = this.operationInput.get(operation);
1796
1865
  const parsed = declaredInput ? declaredInput.parse(input) : input;
1797
1866
  // #129. Refused rather than ignored, for the reason the coordinator refuses an
@@ -1918,8 +1987,16 @@ export function defineScopeDO(modules, bareOps) {
1918
1987
  // commit together, or a throw (from either) rolls domain writes AND
1919
1988
  // emitted events back as one — verified across `await` in workerd.
1920
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;
1921
1998
  await this.revision.transaction(async () => {
1922
- 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);
1923
2000
  // #116: a retry is answered from the recording, and nothing else runs
1924
2001
  // — not the guards, not the handler, not the permission check inside
1925
2002
  // it. Keyed by SUBJECT, so a caller only ever reaches its own
@@ -1946,6 +2023,9 @@ export function defineScopeDO(modules, bareOps) {
1946
2023
  const seen = guardedRef && invokeOptions?.ifMatch !== undefined
1947
2024
  ? this.versionAt(guardedRef)
1948
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);
1949
2029
  await this.runGuards(operation, ctx, parsed);
1950
2030
  result = await handler(ctx, parsed);
1951
2031
  if (guardedRef && invokeOptions?.ifMatch !== undefined) {
@@ -2142,12 +2222,57 @@ export function defineScopeDO(modules, bareOps) {
2142
2222
  if (withinHeader !== null && !within) {
2143
2223
  return new Response('live reads: unreadable within narrowing', { status: 500 });
2144
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
+ }
2145
2231
  // A subscriber arriving before the scope's migrations have run would be told
2146
2232
  // about events against a schema it cannot read back through. Same gate every
2147
2233
  // other entry point takes, for the same reason.
2148
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
+ }
2149
2258
  const pair = new WebSocketPair();
2150
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
+ }
2151
2276
  // HIBERNATABLE, not `server.accept()`. A scope with a watcher open would
2152
2277
  // otherwise be pinned in memory for as long as somebody has a tab open, which is
2153
2278
  // the cost model inverted: a support desk being WATCHED is the normal state.
@@ -2155,21 +2280,94 @@ export function defineScopeDO(modules, bareOps) {
2155
2280
  // eviction, so the socket would stay open and silently stop receiving, which is
2156
2281
  // indistinguishable from a quiet scope.
2157
2282
  this.ctx.acceptWebSocket(server);
2158
- // `.parse`, not a cast: these three arrived as header strings, and the branded
2159
- // ids are what every check downstream is keyed on. A malformed one would
2160
- // otherwise be carried all the way to a `ctx.check` that quietly matches nothing —
2161
- // which reads as "this subscriber may see nothing" and is indistinguishable from
2162
- // a correct denial. Refused here instead, where it is still one subscriber's
2163
- // problem. Throwing is right: the coordinator built this request.
2164
2283
  server.serializeAttachment({
2165
- principal: principalId.parse(principal),
2166
- tenantId: tenantIdOf.parse(tenantId),
2167
- scopeId: scopeIdOf.parse(scopeId),
2284
+ ...subscriber,
2168
2285
  since: new Date().toISOString(),
2169
2286
  ...(within ? { within } : {}),
2287
+ ...(expiresAt ? { expiresAt } : {}),
2170
2288
  });
2289
+ if (expiresAt)
2290
+ await this.armLiveExpiry();
2171
2291
  return new Response(null, { status: 101, webSocket: client });
2172
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
+ }
2173
2371
  /**
2174
2372
  * A subscriber said something.
2175
2373
  *
@@ -2189,6 +2387,9 @@ export function defineScopeDO(modules, bareOps) {
2189
2387
  */
2190
2388
  webSocketMessage(ws, message) {
2191
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.
2192
2393
  if (message === 'ping')
2193
2394
  ws.send('pong');
2194
2395
  }
@@ -2322,12 +2523,33 @@ export function defineScopeDO(modules, bareOps) {
2322
2523
  * operation has already moved the edge the walk follows.
2323
2524
  */
2324
2525
  const parents = scopeTupleReader(this.sql);
2325
- 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();
2326
2531
  const ancestors = new Map();
2327
- 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) => {
2328
2549
  let up = ancestors.get(row.id);
2329
2550
  if (!up) {
2330
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.
2331
2553
  up = ancestorsWithin(parents, { entityType: row.entity_type, entityId: row.entity_id }, now).catch(() => new Set());
2332
2554
  ancestors.set(row.id, up);
2333
2555
  }
@@ -2351,6 +2573,18 @@ export function defineScopeDO(modules, bareOps) {
2351
2573
  // assumption about how the subscription was created.
2352
2574
  if (subscription.tenantId !== tenantId || subscription.scopeId !== scopeId)
2353
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
+ }
2354
2588
  const { within } = subscription;
2355
2589
  // One context per subscriber, not per event: `ctx.check` is the expensive part
2356
2590
  // and the context is only the subject it is evaluated for. Built on first use, so
@@ -2366,57 +2600,115 @@ export function defineScopeDO(modules, bareOps) {
2366
2600
  // The operation name is carried anyway, for the events a fan-out cannot emit
2367
2601
  // but a future reader of this context might.
2368
2602
  let ctx;
2369
- const { principal } = subscription;
2370
- const context = () => (ctx ??= this.operationContext(principal, tenantId, scopeId, undefined, undefined, undefined, undefined, undefined, 'live.subscribe'));
2371
- 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;
2372
2620
  // Narrowing first: it is memoised across sockets, and a row outside the root
2373
2621
  // is out whatever the principal holds.
2374
- if (within && !(await reaches(row, within)))
2375
- continue;
2376
- let frame;
2377
- if (within?.vouched !== undefined) {
2378
- // The vertical vouched for the root, so the walk above was the whole filter
2379
- // — 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
2380
2644
  // something beneath its root changed. Never which row, never how.
2381
- frame = { kind: 'nudge', id: row.id, at: row.occurred_at };
2645
+ return { kind: 'nudge', id: row.id, at: row.occurred_at };
2382
2646
  }
2383
- else {
2384
- // Non-null: `announceable` is exactly the rows whose type is in the map.
2385
- const permission = this.liveTargets.get(row.entity_type);
2386
- let allowed = false;
2387
- try {
2388
- const decision = await context().check(permission, {
2389
- entityType: row.entity_type,
2390
- entityId: row.entity_id,
2391
- });
2392
- allowed = decision.allowed;
2393
- }
2394
- catch {
2395
- // A check that cannot answer is a check that refuses. The alternative —
2396
- // treating an evaluator failure as an allow — turns an outage in the
2397
- // permission path into a disclosure, which is the one failure mode this
2398
- // surface must not have.
2399
- allowed = false;
2400
- }
2401
- if (!allowed)
2402
- continue;
2403
- frame = {
2404
- kind: 'change',
2405
- id: row.id,
2406
- 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, {
2407
2651
  entityType: row.entity_type,
2408
2652
  entityId: row.entity_id,
2409
- at: row.occurred_at,
2410
- };
2411
- }
2412
- try {
2413
- ws.send(JSON.stringify(frame));
2653
+ });
2654
+ if (!decision.allowed)
2655
+ return 'skip';
2414
2656
  }
2415
2657
  catch {
2416
- // The socket went away between `getWebSockets()` and here. Stop writing to
2417
- // this one and move on; the runtime will deliver `webSocketClose`.
2418
- 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;
2419
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.
2420
2712
  }
2421
2713
  }
2422
2714
  }
@@ -2653,6 +2945,20 @@ export function defineScopeDO(modules, bareOps) {
2653
2945
  await this.ensureMigrations();
2654
2946
  return this.queue.enqueue(async () => this.revision.transactionSync(() => recordAttachmentText(doSpineSql(this.sql), attachmentId, outcome, new Date().toISOString())));
2655
2947
  }
2948
+ /**
2949
+ * #1575: start the scope's one-shot backfill, unless it is marked or holds no
2950
+ * attachments (`startAttachmentTextBackfill` reads before it writes, so a marked scope
2951
+ * writes nothing — and moves no write revision — on every drive).
2952
+ */
2953
+ async attachmentTextBackfillStart() {
2954
+ await this.ensureMigrations();
2955
+ await this.queue.enqueue(async () => this.revision.transactionSync(() => startAttachmentTextBackfill(doSpineSql(this.sql), ulid(), new Date().toISOString())));
2956
+ }
2957
+ /** #1575: one backfill batch, in one transaction (`queueAttachmentTextBackfill`). */
2958
+ async attachmentTextBackfillBatch(after) {
2959
+ await this.ensureMigrations();
2960
+ return this.queue.enqueue(async () => this.revision.transactionSync(() => queueAttachmentTextBackfill(doSpineSql(this.sql), after, ulid, new Date().toISOString())));
2961
+ }
2656
2962
  // -- attachments through a capability (#1686) -------------------------------
2657
2963
  // The coordinator hashed the session token; only the hash arrives. Each verb resolves it
2658
2964
  // INSIDE the queue, as `invoke` does, so nothing can revoke between the resolution and
@@ -2838,8 +3144,7 @@ export function defineScopeDO(modules, bareOps) {
2838
3144
  return this.queue.enqueue(() => {
2839
3145
  if (systemSwitchedOff(this.switchSql(), moduleId))
2840
3146
  return false;
2841
- this.sql.exec(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at)
2842
- VALUES (?, ?, ?, ?)`, `system:${moduleId}`, relation, object, expiresAt);
3147
+ writeExplicitTupleIn(this.switchSql(), `system:${moduleId}`, relation, object, { kind: 'replace', expiresAt });
2843
3148
  return true;
2844
3149
  });
2845
3150
  }
@@ -2931,12 +3236,33 @@ export function defineScopeDO(modules, bareOps) {
2931
3236
  return this.queue.enqueue(async () => {
2932
3237
  const bound = await this.assignmentBound({ kind: 'principal', id: caller }, tenantId, scopeId, roleKey);
2933
3238
  if (bound?.covered) {
2934
- this.sql.exec(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at)
2935
- VALUES (?, ?, ?, NULL)`, `principal:${assignee}`, `role:${roleKey}`, `scope:${scopeId}`);
3239
+ applyScopeRoleChange(this.switchSql(), scopeId, assignee, { revoke: [], grant: roleKey }, new Date().toISOString());
2936
3240
  }
2937
3241
  return bound;
2938
3242
  });
2939
3243
  }
3244
+ /** The scope's live scope-level role assignments, or one principal's (#1150). */
3245
+ async scopeRoleHoldersFor(scopeId, principal) {
3246
+ await this.ensureMigrations();
3247
+ return this.queue.enqueue(() => scopeRoleHolders(this.switchSql(), scopeId, new Date().toISOString(), principal));
3248
+ }
3249
+ /**
3250
+ * The kernel's `changeScopeRole` in one serialized scope task (#1150). Its two refusals come
3251
+ * back as values: an error thrown here crosses the RPC flattened, so the coordinator types them.
3252
+ */
3253
+ async changeScopeRoleBoundedFor(tenantId, scopeId, caller, principal, from, to) {
3254
+ await this.ensureMigrations();
3255
+ return this.queue.enqueue(() => changeScopeRole(this.switchSql(), scopeId, principal, from, to, new Date().toISOString(), this.roleBound(caller, tenantId, scopeId), (run) => this.revision.transactionSync(run)));
3256
+ }
3257
+ /** The kernel's `revokeScopeRoles` in one serialized scope task (#1150). */
3258
+ async revokeScopeRolesBoundedFor(tenantId, scopeId, caller, principal) {
3259
+ await this.ensureMigrations();
3260
+ return this.queue.enqueue(() => revokeScopeRoles(this.switchSql(), scopeId, principal, new Date().toISOString(), this.roleBound(caller, tenantId, scopeId), (run) => this.revision.transactionSync(run)));
3261
+ }
3262
+ /** The caller's bound per role at the scope; `null` for a role the tenant does not define. */
3263
+ roleBound(caller, tenantId, scopeId) {
3264
+ return (roleKey) => this.assignmentBound({ kind: 'principal', id: caller }, tenantId, scopeId, roleKey);
3265
+ }
2940
3266
  /**
2941
3267
  * §5.1's assignment bound, resolved against the SAME role table the checker
2942
3268
  * expands — the tenant's projected role, not a vertical's compile-time `ROLES`
@@ -3207,6 +3533,70 @@ export function defineScopeDO(modules, bareOps) {
3207
3533
  await this.ensureMigrations();
3208
3534
  return readCapabilityPage(doSpineSql(this.sql), filter);
3209
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
+ }
3210
3600
  /**
3211
3601
  * The last time a schedule's operation ran on this scope (#383), or null.
3212
3602
  *
@@ -3547,18 +3937,55 @@ export function defineScopeDO(modules, bareOps) {
3547
3937
  this.migrationPromise = this.applyPendingMigrations();
3548
3938
  return this.migrationPromise;
3549
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
+ }
3550
3974
  /** Resolves true if this call applied at least one migration. */
3551
3975
  async applyPendingMigrations() {
3552
- const pending = [];
3553
- for (const mod of this.modules.values()) {
3554
- for (const migration of mod.migrations) {
3555
- if (!this.applied.has(`${mod.id}@${migration.version}`)) {
3556
- pending.push({ moduleId: mod.id, migration });
3557
- }
3558
- }
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);
3559
3981
  }
3560
- 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());
3561
3987
  return false;
3988
+ }
3562
3989
  this.migrationRuns += 1;
3563
3990
  await this.queue.enqueue(async () => {
3564
3991
  // #286: bookmark the instant before an UPGRADE migrates live data — the
@@ -3581,34 +4008,53 @@ export function defineScopeDO(modules, bareOps) {
3581
4008
  // (#278) remains the fallback rewind point.
3582
4009
  }
3583
4010
  }
3584
- for (const { moduleId, migration } of pending) {
4011
+ for (const [i, { moduleId, migration, digest, authored }] of pending.entries()) {
3585
4012
  const key = `${moduleId}@${migration.version}`;
3586
4013
  if (this.applied.has(key))
3587
4014
  continue;
4015
+ let recorded = digest;
3588
4016
  try {
3589
4017
  await this.revision.transaction(async () => {
3590
4018
  const already = this.sql
3591
- .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)
3592
4020
  .toArray()[0];
3593
- 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 {
3594
4029
  const started = performance.now();
3595
4030
  const before = this.sql.exec('SELECT total_changes() AS n').toArray()[0].n;
3596
- // #1898: a migration runs on this DO's own handle, not `ctx.sql`, so the
3597
- // spine guard's REFERENCES rule is applied here.
3598
- 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}`);
3599
4037
  // #1722: not counted per statement, so `total_changes()` measures the migration
3600
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;
3601
4041
  this.revisionSuspended = true;
3602
4042
  try {
3603
- for (const stmt of splitSqlStatements(migration.sql)) {
4043
+ steps = runMigrationStatements(doSpineSql(this.sql), migration.sql, (stmt) => {
3604
4044
  this.sql.exec(stmt);
3605
- }
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);
3606
4050
  }
3607
4051
  finally {
3608
4052
  this.revisionSuspended = false;
3609
4053
  }
3610
4054
  const after = this.sql.exec('SELECT total_changes() AS n').toArray()[0].n;
3611
- 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);
3612
4058
  }
3613
4059
  });
3614
4060
  }
@@ -3622,9 +4068,9 @@ export function defineScopeDO(modules, bareOps) {
3622
4068
  // It is not an unhandled rejection: every caller awaits the memoised promise, and
3623
4069
  // the coordinator records the failure it receives (#1898 review).
3624
4070
  this.lastFailure = { version: key, error: err.message };
3625
- throw new Error(`migration failed for ${key} — scope fails closed: ${err.message}`);
4071
+ throw migrationFailedError(key, err.message);
3626
4072
  }
3627
- this.applied.add(key);
4073
+ this.applied.set(key, recorded);
3628
4074
  }
3629
4075
  });
3630
4076
  return true;
@@ -3863,6 +4309,30 @@ export function defineScopeDO(modules, bareOps) {
3863
4309
  attempts = attempts + 1, settled_at = ?
3864
4310
  WHERE id = ? AND status = 'pending'`, status, result, lastError, lastFailure, status === 'pending' ? null : new Date().toISOString(), id);
3865
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
+ }
3866
4336
  /**
3867
4337
  * Turn one connector delivery into a `connector:<provider>` platform intent (#574
3868
4338
  * phase 3) — the CP-less host's substitute for running the handler it cannot run.
@@ -3956,7 +4426,7 @@ export function defineScopeDO(modules, bareOps) {
3956
4426
  /** Every table in this scope's DB, with row counts; spine/internal tables flagged. */
3957
4427
  introspectTables() {
3958
4428
  const names = this.sql
3959
- .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`)
3960
4430
  .toArray().map((r) => r.name);
3961
4431
  return names.map((name) => ({
3962
4432
  name,
@@ -3980,7 +4450,7 @@ export function defineScopeDO(modules, bareOps) {
3980
4450
  }
3981
4451
  tablePage(table, limit, offset) {
3982
4452
  const known = new Set(this.sql
3983
- .exec(`SELECT name FROM sqlite_master WHERE type = 'table'`)
4453
+ .exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB '_cf_*'`)
3984
4454
  .toArray().map((r) => r.name));
3985
4455
  if (!known.has(table))
3986
4456
  throw substratError('not_found', `unknown table '${table}'`);
@@ -4018,6 +4488,7 @@ export function defineScopeDO(modules, bareOps) {
4018
4488
  }
4019
4489
  async readOnlyQuery(sql) {
4020
4490
  const stmt = assertReadOnlyQuery(sql);
4491
+ assertNoWorkerdStorage(stmt);
4021
4492
  let result;
4022
4493
  const rollback = new Error('read-only console rollback');
4023
4494
  try {
@@ -4095,7 +4566,7 @@ export function defineScopeDO(modules, bareOps) {
4095
4566
  // carries the rows, never the index over them.
4096
4567
  const defs = this.sql
4097
4568
  .exec(`SELECT name, sql FROM sqlite_master
4098
- 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
4099
4570
  ORDER BY name`)
4100
4571
  .toArray().filter(({ name }) => !isSearchIndexTable(name));
4101
4572
  return defs.map(({ name, sql }) => {
@@ -4158,6 +4629,8 @@ export function defineScopeDO(modules, bareOps) {
4158
4629
  // #1763: rows written before these fields keep NULL, meaning unrecorded.
4159
4630
  'ALTER TABLE _substrat_migrations ADD COLUMN duration_ms INTEGER',
4160
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',
4161
4634
  'ALTER TABLE _substrat_tuples ADD COLUMN revoked_at TEXT',
4162
4635
  // #1632: legacy runs retain an unknown subject; no content-based backfill.
4163
4636
  'ALTER TABLE _substrat_job_runs ADD COLUMN subject_id TEXT',
@@ -4214,6 +4687,9 @@ export function defineScopeDO(modules, bareOps) {
4214
4687
  // every legacy row — a past run's id cannot be recovered afterwards, exactly
4215
4688
  // as the other #1525 columns above argued.
4216
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',
4217
4693
  // #2009: the copy classification on a scope DO built before it (NULL reads as a copy;
4218
4694
  // see `COPY_ORIGIN_DDL`).
4219
4695
  'ALTER TABLE _substrat_copy_origin ADD COLUMN is_copy INTEGER',
@@ -4239,6 +4715,9 @@ export function defineScopeDO(modules, bareOps) {
4239
4715
  // boot. `lint:spine-ddl` compares KERNEL_DDL's indexes only, so this one is held to
4240
4716
  // both adapters by the query-plan test rather than by that gate.
4241
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);
4242
4721
  this.ensureScheduleStateKind();
4243
4722
  this.ensureRefusalsAdmitGuards();
4244
4723
  }
@@ -4279,7 +4758,7 @@ export function defineScopeDO(modules, bareOps) {
4279
4758
  // synchronous, which is the one case the sync API is for (it commits at the
4280
4759
  // first await, and there is none). It also has to be sync because the caller is.
4281
4760
  this.revision.transactionSync(() => {
4282
- for (const stmt of splitSqlStatements(script))
4761
+ for (const stmt of executableSqlStatements(script))
4283
4762
  this.sql.exec(stmt);
4284
4763
  });
4285
4764
  }
@@ -4356,7 +4835,9 @@ export function defineScopeDO(modules, bareOps) {
4356
4835
  this.sql.exec('PRAGMA defer_foreign_keys = ON');
4357
4836
  // Real tables only; `sqlite_*` internals are auto-managed and un-droppable.
4358
4837
  const existing = this.sql
4359
- .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_*'`)
4360
4841
  .toArray();
4361
4842
  // Search index tables are left alone here and rebuilt below (#827): dropping a
4362
4843
  // shadow table directly is an error, and `sqlite_master` order would reach one
@@ -4371,9 +4852,12 @@ export function defineScopeDO(modules, bareOps) {
4371
4852
  // decide how this DO's permission checks match. Every `_substrat_*` table is built
4372
4853
  // from KERNEL_DDL instead, and the dump contributes only rows, by column name
4373
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.
4374
4858
  for (const t of replayable)
4375
4859
  if (!isSpineTable(t.name))
4376
- this.sql.exec(t.ddl);
4860
+ this.sql.exec(blankSqlComments(t.ddl));
4377
4861
  // KERNEL_DDL also builds what the dump did not carry (#321). A dump captured from a
4378
4862
  // WORLD that stores some `_substrat_*` tables ELSEWHERE carries only a subset — an
4379
4863
  // `@substrat-run/adapter-sqlite` scope file keeps `_substrat_roles` /
@@ -4383,11 +4867,12 @@ export function defineScopeDO(modules, bareOps) {
4383
4867
  // restore's repair leg (host.projectRolesLocal) — the spine's job is only to exist so
4384
4868
  // the checker can read it. The column pass follows, for the one outbox index KERNEL_DDL
4385
4869
  // leaves to it.
4386
- for (const stmt of splitSqlStatements(KERNEL_DDL))
4870
+ for (const stmt of executableSqlStatements(KERNEL_DDL))
4387
4871
  this.sql.exec(stmt);
4388
4872
  this.applySpineColumnAdditions();
4389
4873
  const columnsOf = (name) => doBuiltColumnsOf(this.sql, name);
4390
4874
  assertSpineTablesBuilt(replayable.map((t) => t.name), columnsOf);
4875
+ assertJournalDumpCoherent(replayable);
4391
4876
  // A spine column this kernel does not know (a dump from a newer one) is kept, as a plain
4392
4877
  // untyped column the checker never reads.
4393
4878
  for (const t of replayable) {
@@ -4438,6 +4923,17 @@ export function defineScopeDO(modules, bareOps) {
4438
4923
  if (destScopeId) {
4439
4924
  this.rewriteScopeTuples(destScopeId, sourceScopeId && { scopeId: sourceScopeId, exact }, new Date().toISOString());
4440
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
+ });
4441
4937
  // #1742: the recorded-off modules go back off INSIDE the replay's transaction, with
4442
4938
  // the spine and the re-point. A switch that throws rolls the whole restore back, so
4443
4939
  // the dump's grants never commit live without the switch that should cover them.
@@ -4450,20 +4946,6 @@ export function defineScopeDO(modules, bareOps) {
4450
4946
  }
4451
4947
  });
4452
4948
  this.carriedAwayCopy = this.metaValue(CARRIED_AWAY_KEY) !== null;
4453
- // Rebuild the derived search indexes over the rows just loaded (#827). Drop-then-
4454
- // create, so it also repairs an index a dump left stale, and the triggers it
4455
- // recreates are what keep the restored scope in step from here. Skipped for a plan
4456
- // whose content table this dump did not carry — a restore must not invent a table
4457
- // for an index to point at.
4458
- const present = new Set(this.sql
4459
- .exec(`SELECT name FROM sqlite_master WHERE type = 'table'`)
4460
- .toArray().map((r) => r.name));
4461
- for (const plan of this.searchPlans.values()) {
4462
- if (!present.has(plan.table))
4463
- continue;
4464
- for (const stmt of splitSqlStatements(searchIndexDdl(plan)))
4465
- this.sql.exec(stmt);
4466
- }
4467
4949
  // #1335 / #1686: the outbox arrived with the dump, so this DO's event ids resume above it,
4468
4950
  // as on a wake. A copy's own events then sort above every copied one, which is what
4469
4951
  // `emittedHere()` relies on. A dump whose top id is no ULID leaves the floor where it was.
@@ -4478,12 +4960,7 @@ export function defineScopeDO(modules, bareOps) {
4478
4960
  }
4479
4961
  // The frontier arrived with the dump — refresh the in-memory applied set so a
4480
4962
  // later migrate() builds on the imported state, not the provisioning state.
4481
- this.applied.clear();
4482
- for (const row of this.sql
4483
- .exec('SELECT module_id, version FROM _substrat_migrations')
4484
- .toArray()) {
4485
- this.applied.add(`${row.module_id}@${row.version}`);
4486
- }
4963
+ this.applied = readAppliedMigrations(this.sql);
4487
4964
  // …and forget that this INSTANCE ever ran a migration pass (#1589). Refreshing
4488
4965
  // the set above is not enough on its own: `ensureMigrations` memoises its
4489
4966
  // promise, so a warm DO answers "already migrated" from the cache and never
@@ -4507,6 +4984,12 @@ export function defineScopeDO(modules, bareOps) {
4507
4984
  // is complete applies nothing, so there is nothing to report either way.
4508
4985
  this.migrationPromise = undefined;
4509
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);
4510
4993
  return switched;
4511
4994
  }
4512
4995
  /**
@@ -4743,9 +5226,41 @@ export function defineScopeDO(modules, bareOps) {
4743
5226
  * outranks their Article 17 right.
4744
5227
  */
4745
5228
  async redactSubject(subjectId) {
5229
+ // #2068: the module half reaches the scope's own tables, so they have to exist.
5230
+ await this.ensureMigrations();
4746
5231
  // One instant for the whole erasure — the intent tombstones must not disagree with
4747
5232
  // each other about when a person was erased.
4748
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
+ });
4749
5264
  const doomed = this.sql
4750
5265
  .exec(`SELECT id FROM _substrat_outbox
4751
5266
  WHERE subject_id = ? AND pii_class != 'none' AND payload IS NOT NULL`, subjectId)
@@ -4766,6 +5281,7 @@ export function defineScopeDO(modules, bareOps) {
4766
5281
  // The free-text copies (#1632), and the tombstoned intents the coordinator hands to
4767
5282
  // the directory half. Last, so those ids include every intent tombstoned above.
4768
5283
  ...redactSubjectScopeText(sql, subjectId, at),
5284
+ vertical,
4769
5285
  };
4770
5286
  }
4771
5287
  /**
@@ -4792,36 +5308,17 @@ export function defineScopeDO(modules, bareOps) {
4792
5308
  return redacted;
4793
5309
  }
4794
5310
  // -- event dispatch (port of dispatch) ------------------------------------
4795
- /**
4796
- * #1237: the event currently being delivered, or null.
4797
- *
4798
- * The host carries a field of the same name for the admin log, and it is
4799
- * unreachable from here — it lives in the worker that holds the stub, while every
4800
- * emit runs inside this Durable Object. So the DO keeps its own, and the two are
4801
- * deliberately separate rather than one passed across the hop.
4802
- *
4803
- * One instance IS one scope, so this field is per-scope by construction. The
4804
- * SQLite twin has to say so explicitly (`ScopeRuntime.causedBy`): that host holds
4805
- * every scope in the process, so the same field on the host would be read by
4806
- * another scope's emit the moment a consumer awaits.
4807
- */
4808
- causedBy = null;
4809
5311
  /**
4810
5312
  * #1237: the invocation currently running in this DO, or null.
4811
5313
  *
4812
- * DO-local, like `causedBy` and for a simpler reason: a Durable Object IS one
4813
- * scope, so there is no other scope's call to confuse it with. It still has to be
4814
- * cleared, because the DO outlives the request and a value left set would stamp a
4815
- * later alarm-driven drain with a call it had nothing to do with.
5314
+ * DO-local: a Durable Object IS one scope, so there is no other scope's call to
5315
+ * confuse it with, and `invoke` sets and clears it inside its queued body, which no
5316
+ * other emitting body interleaves with. It still has to be cleared, because the DO
5317
+ * outlives the request and a value left set would stamp a later alarm-driven drain
5318
+ * with a call it had nothing to do with. A consumer's own cause is not kept here: it is
5319
+ * passed into the consumer's context (`ConsumerDelivery`, #2055).
4816
5320
  */
4817
5321
  invocationId = null;
4818
- /**
4819
- * #1901: the id a unit of async work with no call around it logs under — a consumer a
4820
- * seed or an import delivered — set for its handler's duration and cleared after. Read
4821
- * by `ctx.log` only, never the spine: an event a consumer emits outside a call still
4822
- * records no invocation (#1525), while its log lines join the consumer's own line.
4823
- */
4824
- unitInvocationId = null;
4825
5322
  async dispatch(tenantId, scopeId,
4826
5323
  /**
4827
5324
  * #1525: the call this drain is running in, or null. Every round of the loop
@@ -4884,16 +5381,15 @@ export function defineScopeDO(modules, bareOps) {
4884
5381
  lines.write({ ...unit, outcome: 'dead-lettered' });
4885
5382
  continue;
4886
5383
  }
4887
- // #1237: anything this consumer emits was emitted BECAUSE of this event
4888
- // — the step a backwards walk used to stop dead at, since a consumer
4889
- // emit records no operation either.
4890
- this.causedBy = event.id;
4891
- // #1901: the handler's `ctx.log` lines join this delivery's line.
4892
- this.unitInvocationId = unit.invocationId;
4893
5384
  try {
4894
5385
  await this.revision.transaction(async () => {
4895
- const ctx = this.operationContext(this.systemPrincipal, tenantId, scopeId, {
4896
- system: mod.id,
5386
+ // #1237: anything this consumer emits was emitted BECAUSE of this event —
5387
+ // the step a backwards walk used to stop dead at, since a consumer emit
5388
+ // records no operation either. #1901: its `ctx.log` lines join this
5389
+ // delivery's line.
5390
+ const ctx = this.consumerContext(tenantId, scopeId, mod.id, {
5391
+ causedBy: event.id,
5392
+ invocationId: unit.invocationId,
4897
5393
  });
4898
5394
  await consumer.handler(ctx, event);
4899
5395
  this.sql.exec(`INSERT INTO _substrat_deliveries
@@ -4911,14 +5407,6 @@ export function defineScopeDO(modules, bareOps) {
4911
5407
  VALUES (?, ?, ?, ?, ?)`, event.id, mod.id, new Date().toISOString(), String(err), invocationId);
4912
5408
  lines.write({ ...unit, outcome: 'dead-lettered', error: err });
4913
5409
  }
4914
- finally {
4915
- this.unitInvocationId = null;
4916
- // Cleared on BOTH paths. Left set, the id leaks onto every later emit
4917
- // this DO makes — an operation's own event stamped as caused by
4918
- // whatever was delivered last, which is worse than recording nothing
4919
- // because it reads as a fact.
4920
- this.causedBy = null;
4921
- }
4922
5410
  }
4923
5411
  }
4924
5412
  }
@@ -5033,11 +5521,16 @@ export function defineScopeDO(modules, bareOps) {
5033
5521
  * the queued body. Mutually exclusive with the other non-person callers; `principal` is
5034
5522
  * then a placeholder that the subject below never reads.
5035
5523
  */
5036
- peerSubject) {
5524
+ peerSubject,
5525
+ /** #2055: the delivery a consumer's or an import's context runs for; absent otherwise. */
5526
+ delivery,
5527
+ /** #119: keys this context's checks refuse whatever the subject holds (`purgeOnlyKeysOf`). */
5528
+ withheld) {
5037
5529
  const checker = this.checker;
5038
5530
  const relations = this.relations;
5039
5531
  const searchPlans = this.searchPlans;
5040
5532
  const listPlans = this.listPlans;
5533
+ const statePlans = this.statePlans;
5041
5534
  const sql = this.sql;
5042
5535
  const mintEventId = this.mintEventId;
5043
5536
  /**
@@ -5093,6 +5586,9 @@ export function defineScopeDO(modules, bareOps) {
5093
5586
  // #1642: parsed before the system actor's early return, which never reaches
5094
5587
  // the checker — a cast key would otherwise become that path's proof relation.
5095
5588
  const permission = assertPermissionKey(unparsed);
5589
+ if (withheld?.has(permission)) {
5590
+ return { allowed: false, checked: permission, node: { tenantId, scopeId } };
5591
+ }
5096
5592
  if (systemActor) {
5097
5593
  return {
5098
5594
  allowed: true,
@@ -5160,10 +5656,10 @@ export function defineScopeDO(modules, bareOps) {
5160
5656
  // about the deploy, so it never rides `DomainEvent` for module code to
5161
5657
  // branch on; it exists for the observability joins the column serves.
5162
5658
  this.env.SUBSTRAT_VERSION_ID ?? null,
5163
- // #1237: whatever delivery is in flight, if any — read off the DO the same
5164
- // way the version is read off its env. A fact about the surrounding
5165
- // dispatch, never envelope data module code could set or branch on.
5166
- this.causedBy,
5659
+ // #1237: the delivery this context was built for, if any — passed in, never read
5660
+ // off the DO (#2055). A fact about the surrounding dispatch, never envelope data
5661
+ // module code could set or branch on.
5662
+ delivery?.causedBy ?? null,
5167
5663
  // #1237: a fact about the surrounding CALL, like the version above.
5168
5664
  this.invocationId, full.payload === undefined ? null : JSON.stringify(full.payload));
5169
5665
  };
@@ -5173,7 +5669,13 @@ export function defineScopeDO(modules, bareOps) {
5173
5669
  // #1672: a capability's own id stands in so the type holds — it is not a person, and
5174
5670
  // the event actor says what it is instead. Every other door passes its own value.
5175
5671
  principal: capabilityId ? capabilityId : principal,
5176
- sql: guardSecrets(doScopedSql(sql), minted),
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()),
5673
+ sql: guardSecrets(doScopedSql(sql, statefulTablesOf(statePlans),
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())
5678
+ : undefined), minted),
5177
5679
  now: () => at,
5178
5680
  // #1746/#1747: the host stamps who and where; module code supplies only the template
5179
5681
  // and its fields. A consumer runs under the system override, so it logs as `system`.
@@ -5182,7 +5684,7 @@ export function defineScopeDO(modules, bareOps) {
5182
5684
  tenantId,
5183
5685
  scopeId,
5184
5686
  operation: operation ?? null,
5185
- invocationId: () => this.invocationId ?? this.unitInvocationId,
5687
+ invocationId: () => this.invocationId ?? delivery?.invocationId ?? null,
5186
5688
  principalKind: systemActor ? 'system' : subject.kind,
5187
5689
  // The string-safe redaction: `redactSecrets` parses its serialization back, and a
5188
5690
  // log's text is not JSON.
@@ -5231,6 +5733,10 @@ export function defineScopeDO(modules, bareOps) {
5231
5733
  return entityVersionOf(sql.exec(q.sql, ...q.params).toArray());
5232
5734
  },
5233
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
+ },
5234
5740
  canAssign: runCanAssign,
5235
5741
  // #827. Mirror of the pure adapter: the plan comes from registration, the
5236
5742
  // rows from this DO's own SQLite, and the index is maintained by triggers
@@ -5240,7 +5746,9 @@ export function defineScopeDO(modules, bareOps) {
5240
5746
  const plan = searchPlans.get(entityType);
5241
5747
  if (!plan)
5242
5748
  throw new NotSearchable(entityType);
5243
- const q = searchQuery(plan, searchMatchExpression(term, plan.tokenizer), searchLimit(options?.limit));
5749
+ const q = searchQuery(plan, searchMatchExpression(term, plan.tokenizer), searchLimit(options?.limit),
5750
+ // #119: active rows unless the archive is asked for.
5751
+ searchStateWhere(statePlans, entityType, options?.view));
5244
5752
  return sql.exec(q.sql, ...q.params).toArray().map((row) => ({ entityType, id: row.id, rank: row.rank }));
5245
5753
  },
5246
5754
  /**
@@ -5260,17 +5768,18 @@ export function defineScopeDO(modules, bareOps) {
5260
5768
  order: params.order,
5261
5769
  cursor: params.cursor,
5262
5770
  filters: params.filters,
5771
+ // #119: the bin is the checked reader's, never this one's.
5772
+ view: uncheckedView('ctx.page', entityType, params.view),
5263
5773
  });
5264
5774
  const rows = sql.exec(q.sql, ...q.params).toArray();
5265
- const last = rows.length >= limit ? rows[rows.length - 1] : undefined;
5266
- const nextCursor = last === undefined ? null : cursorOf(last, q.sortColumn, plan.idColumn, q.order);
5267
- 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);
5268
5777
  if (!params.total)
5269
- return page;
5778
+ return pageOf(rows, limit, mint, params.rowCursors);
5270
5779
  const counted = sql
5271
5780
  .exec(q.countSql, ...q.countParams)
5272
5781
  .toArray();
5273
- return { ...page, total: counted[0]?.n ?? 0 };
5782
+ return countedPageOf(rows, limit, mint, counted[0]?.n ?? 0, params.rowCursors);
5274
5783
  },
5275
5784
  /**
5276
5785
  * Delegate a permission this caller holds onto one entity — see the
@@ -5288,7 +5797,8 @@ export function defineScopeDO(modules, bareOps) {
5288
5797
  throw new PermissionDenied(`cannot grant '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
5289
5798
  'the caller does not hold it there (a grant delegates, it never elevates)');
5290
5799
  }
5291
- 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' });
5292
5802
  },
5293
5803
  /**
5294
5804
  * Deliberately NOT the #1856 grammar check `grant` and `link` make: a revoke writes
@@ -5302,7 +5812,10 @@ export function defineScopeDO(modules, bareOps) {
5302
5812
  throw new PermissionDenied(`cannot revoke '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
5303
5813
  'the caller does not hold it there');
5304
5814
  }
5305
- 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);
5306
5819
  },
5307
5820
  atomic: createAtomic(runSub, { passed, signals }),
5308
5821
  // #1672: mint / revoke / list, written once in the kernel — the pure adapter hands
@@ -5328,6 +5841,24 @@ export function defineScopeDO(modules, bareOps) {
5328
5841
  now: at,
5329
5842
  emit: (event) => writeEvent(event, 'kernel'),
5330
5843
  assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
5844
+ isUnreachableParent: (entity) => isUnreachableParent(doSpineSql(sql), statePlans, entity),
5845
+ }),
5846
+ // #119: archive and trash — the pure adapter's wiring, over the raw spine seam.
5847
+ ...createEntityStateVerbs({
5848
+ sql: doSpineSql(sql),
5849
+ plans: statePlans,
5850
+ now: at,
5851
+ check: runCheck,
5852
+ emit: (event) => writeEvent(event, 'kernel'),
5853
+ assertWrites: (verb) => assertImpersonationWrites(impersonation, verb),
5854
+ }),
5855
+ ...createTrashedReads({
5856
+ query: (q, params) => sql.exec(q, ...params).toArray(),
5857
+ listPlans,
5858
+ searchPlans,
5859
+ statePlans,
5860
+ check: runCheck,
5861
+ continuation: (entityType, params) => ctxRef.pageContinuation(`trash:${entityType}`, { ...params, view: 'trashed' }),
5331
5862
  }),
5332
5863
  entitlement: async (key) => {
5333
5864
  const held = await entitlementReader().listEntitlements(tenantId);
@@ -5370,6 +5901,29 @@ export function defineScopeDO(modules, bareOps) {
5370
5901
  .toArray()[0];
5371
5902
  return row?.value === 'local' ? 'local' : 'control-plane';
5372
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
+ }
5373
5927
  /**
5374
5928
  * The checker's tenant-tuple/role reader. Chosen **per call** (the source can
5375
5929
  * flip at runtime, and the checker holds this wrapper for the DO's lifetime):