@substrat-run/adapter-cloudflare 0.135.0 → 0.137.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.
Files changed (43) hide show
  1. package/dist/checker.d.ts.map +1 -1
  2. package/dist/checker.js +3 -1
  3. package/dist/checker.js.map +1 -1
  4. package/dist/connector-calls.d.ts +11 -0
  5. package/dist/connector-calls.d.ts.map +1 -0
  6. package/dist/connector-calls.js +10 -0
  7. package/dist/connector-calls.js.map +1 -0
  8. package/dist/control-plane-do.d.ts +77 -3
  9. package/dist/control-plane-do.d.ts.map +1 -1
  10. package/dist/control-plane-do.js +250 -35
  11. package/dist/control-plane-do.js.map +1 -1
  12. package/dist/host.d.ts +297 -16
  13. package/dist/host.d.ts.map +1 -1
  14. package/dist/host.js +895 -132
  15. package/dist/host.js.map +1 -1
  16. package/dist/index.d.ts +3 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +2 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/live-reads.d.ts +5 -4
  21. package/dist/live-reads.d.ts.map +1 -1
  22. package/dist/live-reads.js +5 -4
  23. package/dist/live-reads.js.map +1 -1
  24. package/dist/r2.d.ts +2 -1
  25. package/dist/r2.d.ts.map +1 -1
  26. package/dist/r2.js.map +1 -1
  27. package/dist/route-resolver.d.ts +7 -0
  28. package/dist/route-resolver.d.ts.map +1 -1
  29. package/dist/route-resolver.js +4 -0
  30. package/dist/route-resolver.js.map +1 -1
  31. package/dist/scope-do.d.ts +2 -0
  32. package/dist/scope-do.d.ts.map +1 -1
  33. package/dist/scope-do.js +896 -140
  34. package/dist/scope-do.js.map +1 -1
  35. package/dist/scope-sweeper-do.d.ts +32 -6
  36. package/dist/scope-sweeper-do.d.ts.map +1 -1
  37. package/dist/scope-sweeper-do.js +35 -5
  38. package/dist/scope-sweeper-do.js.map +1 -1
  39. package/dist/system-door.d.ts +21 -0
  40. package/dist/system-door.d.ts.map +1 -0
  41. package/dist/system-door.js +13 -0
  42. package/dist/system-door.js.map +1 -0
  43. package/package.json +10 -9
package/dist/scope-do.js CHANGED
@@ -1,13 +1,14 @@
1
1
  import { REWIND_REFUSED } from './rewind-refusal.js';
2
+ import { SYSTEM_DOOR_MOVED } from './system-door.js';
2
3
  import { DurableObject } from 'cloudflare:workers';
3
- 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, assertReplayableDump, REDRAIN_BATCH, } from '@substrat-run/contracts';
4
- 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_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_DDL, refusalInsert, refusedTransitionOf, 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, 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, jobRunListLimit, } from '@substrat-run/kernel';
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_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, 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, } from '@substrat-run/kernel';
5
6
  import { isUpgradeRequest, readSubscription, LIVE_FANOUT_LIMIT, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, } from './live-reads.js';
6
7
  import { OperationQueue } from './serialization.js';
7
8
  import { doScopedSql, doBuiltColumnsOf, doRedactionSql, doSpineSql } from './sql.js';
8
- import { actorOf, admitPeer, collectPeers, switchPeer, assertNoSecret, CAPABILITY_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, createCapabilityVerbs, createEntityEdgeVerbs, exchangeCapability, guardSecrets, mintBecomeCapability, redactSecrets, redactSecretText, moduleLog, resolveCapabilitySession, revokeCapabilityAsPlatform, readCapabilityPage, domainEventOf, facetEvents, readDeadLetters, readLifecycleFlow, readHistory, readInvocation, readUndrainedOutbox, walkEventCause, walkEventEffects, } from '@substrat-run/kernel';
9
+ import { actorOf, admitPeer, collectPeers, switchPeer, assertNoSecret, CAPABILITY_DDL, COPY_ORIGIN_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, createCapabilityVerbs, createEntityEdgeVerbs, exchangeCapability, guardSecrets, mintBecomeCapability, redactSecrets, redactSecretText, moduleLog, asyncInvocationId, asyncLinePass, resolveCapabilitySession, revokeCapabilityAsPlatform, readCapabilityPage, domainEventOf, facetEvents, readDeadLetters, readLifecycleFlow, readHistory, readInvocation, readUndrainedOutbox, walkEventCause, walkEventEffects, } from '@substrat-run/kernel';
9
10
  import { createDoTupleChecker, createLocalControlPlaneReader } from './checker.js';
10
- import { assertNoSpineReference, assertSpineTablesBuilt, dumpRowsInsert, isSpineTable, repointScopeGrants, spineColumnAdditions } from '@substrat-run/kernel';
11
+ 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';
11
12
  /**
12
13
  * The key marking a scope DO whose storage was destroyed (`destroyStorage`).
13
14
  * Written after `deleteAll()` so it survives the wipe, and never cleared: `reaped`
@@ -15,6 +16,21 @@ import { assertNoSpineReference, assertSpineTablesBuilt, dumpRowsInsert, isSpine
15
16
  * without asking the directory.
16
17
  */
17
18
  const REAPED_MARKER = '_substrat_reaped';
19
+ /**
20
+ * The `_substrat_meta` key of the tenant a scope was provisioned for (#1738): written by the first
21
+ * projection, never re-pointed, and what every door's pair check holds a request to (#2016).
22
+ */
23
+ const PROVISIONED_FOR_KEY = 'provisioned_for';
24
+ /** #2016: the refusal of a write that would re-point a scope's tenant, in one wording. */
25
+ const tenantReceiptRefusal = (held, asked) => `refused: this scope was provisioned for tenant ${held}, not ${asked}`;
26
+ /** #2016: the spine tables whose rows say a scope holds state (`ScopeDO.holdsData`). */
27
+ const HOLDS_DATA_TABLES = ['_substrat_migrations', '_substrat_tuples', '_substrat_outbox', '_substrat_schedule_state'];
28
+ /** #2016: why `tenantId` is foreign to a scope (`held`, the receipt `tenantVerdict` read), in one wording. */
29
+ const foreignTenant = (held, tenantId) => held !== null
30
+ ? tenantReceiptRefusal(held, tenantId)
31
+ : `refused: this scope holds role rows for another tenant and none for ${tenantId}`;
32
+ /** #2016: the load refusals `tenantReceiptRefusal` raised, told apart by identity, not by text. */
33
+ const tenantRefusals = new WeakSet();
18
34
  /**
19
35
  * The scope spine, as this adapter builds it — one of two hand-written copies (#969).
20
36
  *
@@ -322,11 +338,15 @@ const KERNEL_DDL = `
322
338
  -- #116: the request-dedupe table, kernel-owned so no vertical migrates for it.
323
339
  ${IDEMPOTENCY_DDL}
324
340
  -- #1745: refused transitions, recorded after the rollback like a denial. Kernel-owned.
325
- ${REFUSALS_DDL}
341
+ ${REFUSALS_TABLE_DDL}
342
+ ${REFUSALS_INDEX}
326
343
  -- #1672: capabilities — authority carried by a secret (a link share), and the sessions
327
344
  -- an exchange trades that secret for. Shared with the pure adapter from
328
345
  -- @substrat-run/kernel so the two cannot part company; the column comments are there.
329
346
  ${CAPABILITY_DDL}
347
+ -- #1686: where a copied scope's data came from, and the event id its own events start above.
348
+ -- Shared with the other adapter from @substrat-run/kernel; the column comments are there.
349
+ ${COPY_ORIGIN_DDL}
330
350
  `;
331
351
  /**
332
352
  * Workers RPC carries a plain `Error`'s MESSAGE faithfully and nothing else. A custom
@@ -362,6 +382,137 @@ function kernelEmit(ctx, event) {
362
382
  throw new Error('kernelEmit: not an operation context this host built');
363
383
  write(event);
364
384
  }
385
+ /**
386
+ * The scope DO's write revision (#1722): the one SQL handle this object writes through, and the
387
+ * one way it opens a transaction, so a writer or a transaction added later cannot slip past it.
388
+ * A carry's conditional restore compares the revision, so every change to the store has to move
389
+ * it: an UPDATE in place (a drain receipt, a redrain) as surely as an emitted event (Codex #2008
390
+ * r2). `suspended` is the wake's idempotent DDL, a load's drop-and-replay and a migration's own
391
+ * statements, which advance it themselves.
392
+ *
393
+ * **Atomic with the write (Codex #2008 r4).** The bump runs synchronously, right BEFORE the first
394
+ * write that needs it, on the same connection: inside a `transactionSync` or `transaction` it
395
+ * commits or rolls back with that transaction's writes, and outside one it joins the same
396
+ * coalesced batch. A bump that fails throws before the write runs, so no write lands under the old
397
+ * revision. Nothing is deferred to a later commit.
398
+ *
399
+ * **Once per run, not per statement** (a bump per statement cost 28.7–32.4% on a write-heavy
400
+ * operation, `write-revision-cost.test.ts`). After a bump, later writes skip it while they are
401
+ * covered by it: until the run ends (a microtask clears the flag at its first await or its end)
402
+ * and until a transaction boundary. Entering a transaction clears it, so each committing
403
+ * transaction holds a bump of its own; leaving one clears it too, so after a rollback (which took
404
+ * the bump with it) or a commit the next write bumps again. Clearing more often only over-counts,
405
+ * and over-counting only refuses a restore that could have landed.
406
+ */
407
+ class WriteRevision {
408
+ raw;
409
+ storage;
410
+ suspended;
411
+ refusal;
412
+ /** Whether the current run, in the current transaction scope, already holds a bump. */
413
+ covered = false;
414
+ /** Inside `bookkeeping`: the one path whose writes advance no revision. */
415
+ keeping = false;
416
+ /** A statement's text is almost always a constant, so its answer is remembered (bounded). */
417
+ writes = new Map();
418
+ sql;
419
+ constructor(raw, storage, suspended,
420
+ /** Why this store takes no write at all right now, or null (a `carried_away` copy, #1722). */
421
+ refusal) {
422
+ this.raw = raw;
423
+ this.storage = storage;
424
+ this.suspended = suspended;
425
+ this.refusal = refusal;
426
+ const exec = (query, ...bindings) => {
427
+ if (this.keeping && this.isWrite(query)) {
428
+ // Bookkeeping takes the copy-marker insert and the lifecycle delivery (#1713) and nothing
429
+ // else: any other write here would be one the revision never saw, which is the hole this
430
+ // class exists to close.
431
+ if (!isCopyMarkInsert(query) && !isLifecycleWrite(query)) {
432
+ throw substratError('internal', `the bookkeeping path takes only the copy-marker insert and the lifecycle delivery (#1722, #1713), not: ${query.slice(0, 80)}`);
433
+ }
434
+ return raw.exec(query, ...bindings);
435
+ }
436
+ if (!this.suspended() && this.isWrite(query)) {
437
+ const refused = this.refusal();
438
+ if (refused)
439
+ throw substratError('conflict', refused);
440
+ if (!this.covered)
441
+ this.bump();
442
+ }
443
+ return raw.exec(query, ...bindings);
444
+ };
445
+ this.sql = new Proxy(raw, {
446
+ get(target, prop) {
447
+ if (prop === 'exec')
448
+ return exec;
449
+ const value = Reflect.get(target, prop, target);
450
+ return typeof value === 'function' ? value.bind(target) : value;
451
+ },
452
+ });
453
+ }
454
+ /**
455
+ * Store bookkeeping that is not the scope's data: marking the store a copy (#2005, Codex #2008
456
+ * r10). That only ever restricts what the store may run and changes no data, so it advances no
457
+ * write revision: a backfill that marks a carry's source between its export and its wipe must
458
+ * not read as a write the carry did not copy (that would keep the copy for nothing). Enforced,
459
+ * not trusted: any write in here but the marker insert (`isCopyMarkInsert`) or the lifecycle
460
+ * delivery (`isLifecycleWrite`, #1713) throws. Clearing a
461
+ * marker is NOT bookkeeping (r11): it loosens the store, so it is a write a carry fences on.
462
+ */
463
+ bookkeeping(run) {
464
+ const was = this.keeping;
465
+ this.keeping = true;
466
+ try {
467
+ return run();
468
+ }
469
+ finally {
470
+ this.keeping = was;
471
+ }
472
+ }
473
+ /** `storage.transactionSync`, at a transaction boundary. */
474
+ transactionSync(run) {
475
+ this.covered = false;
476
+ try {
477
+ return this.storage.transactionSync(run);
478
+ }
479
+ finally {
480
+ this.covered = false;
481
+ }
482
+ }
483
+ /** `storage.transaction`, at a transaction boundary. */
484
+ async transaction(run) {
485
+ this.covered = false;
486
+ try {
487
+ return await this.storage.transaction(run);
488
+ }
489
+ finally {
490
+ this.covered = false;
491
+ }
492
+ }
493
+ bump() {
494
+ // No catch: a bump that cannot be written fails the write it was for.
495
+ this.raw.exec(`INSERT INTO _substrat_meta (key, value) VALUES (?, '1')
496
+ ON CONFLICT (key) DO UPDATE SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT)`, WRITE_REVISION_KEY);
497
+ this.covered = true;
498
+ queueMicrotask(() => {
499
+ this.covered = false;
500
+ });
501
+ }
502
+ isWrite(query) {
503
+ let known = this.writes.get(query);
504
+ if (known === undefined) {
505
+ known = isWriteStatement(query);
506
+ if (this.writes.size >= 512)
507
+ this.writes.clear();
508
+ this.writes.set(query, known);
509
+ }
510
+ return known;
511
+ }
512
+ }
513
+ /** What a write into a wiped copy answers (#1722): the scope's data lives in another script now. */
514
+ const CARRIED_AWAY_WRITE_REFUSAL = 'this copy of the scope was carried to another script and wiped (#1722); it takes no writes — ' +
515
+ 'a request that reaches it was routed before the move';
365
516
  function toRpcError(err) {
366
517
  if (err instanceof Error) {
367
518
  return err.constructor === Error ? err : new Error(err.message);
@@ -510,6 +661,15 @@ export function splitSqlStatements(sql) {
510
661
  out.push(cur.trim());
511
662
  return out;
512
663
  }
664
+ /**
665
+ * #1834: the pin missed. Thrown only by `assertSystemDoor`, and module-private, so no operation can
666
+ * throw one: the two RPCs a door call reaches turn it into the `SystemDoorMoved` ANSWER, by class
667
+ * identity, and an operation's own error, whatever its text, stays a failure.
668
+ */
669
+ class SystemDoorMovedError extends Error {
670
+ }
671
+ /** #1834: the brand only `assertSystemDoor` sets, so nothing else can make a `SystemDoorPass`. */
672
+ const SYSTEM_DOOR_PASSED = Symbol('system door passed');
513
673
  export function defineScopeDO(modules, bareOps) {
514
674
  return class ScopeDO extends DurableObject {
515
675
  sql;
@@ -584,6 +744,21 @@ export function defineScopeDO(modules, bareOps) {
584
744
  * `runInDurableObject` — rather than assuming the runtime's documented behaviour.
585
745
  */
586
746
  webSocketMessagesHandled = 0;
747
+ /**
748
+ * #1722: while true, a write does not advance the write revision. True through the wake's
749
+ * own idempotent DDL (which every wake re-runs, and which changes nothing) and through a
750
+ * load's drop-and-replay, which sets the revision itself once the store is rebuilt.
751
+ */
752
+ revisionSuspended = true;
753
+ /**
754
+ * #1722 (Codex #2008 r7): whether this store is a copy a carry wiped (it holds the
755
+ * `carried_away` tombstone). Such a store takes no write: a stale request still routed here
756
+ * after the wipe would land a write that belongs to no live copy. A load, a rollback's
757
+ * restore, ends it. Read on every wake and after every load.
758
+ */
759
+ carriedAwayCopy = false;
760
+ /** #1722: the write revision, and the one way this object opens a transaction. */
761
+ revision;
587
762
  constructor(ctx, env) {
588
763
  super(ctx, env);
589
764
  // #1860: the client's 45s keep-alive (demos/ticket0/app/src/feed.ts) answered at
@@ -591,11 +766,18 @@ export function defineScopeDO(modules, bareOps) {
591
766
  // subscriber's ping still reaches `webSocketMessage` and pins the DO in memory —
592
767
  // exactly the hibernation cost `acceptWebSocket` (below) exists to avoid.
593
768
  ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair('ping', 'pong'));
594
- this.sql = ctx.storage.sql;
769
+ // #1722: the ONE handle this object writes through and the one way it opens a transaction,
770
+ // so every write advances the write revision a carry's restore is fenced on, inside the
771
+ // transaction that commits it. Nothing else here may take the raw handle or open a
772
+ // transaction on `ctx.storage` itself (`carried-copy.test.ts` in the kernel holds that).
773
+ this.revision = new WriteRevision(ctx.storage.sql, ctx.storage, () => this.revisionSuspended, () => (this.carriedAwayCopy ? CARRIED_AWAY_WRITE_REFUSAL : null));
774
+ this.sql = this.revision.sql;
595
775
  for (const stmt of splitSqlStatements(KERNEL_DDL)) {
596
776
  this.sql.exec(stmt);
597
777
  }
598
778
  this.applySpineColumnAdditions();
779
+ this.revisionSuspended = false;
780
+ this.carriedAwayCopy = this.metaValue(CARRIED_AWAY_KEY) !== null;
599
781
  for (const registration of modules)
600
782
  this.registerModule(registration);
601
783
  for (const [name, handler] of Object.entries(bareOps))
@@ -860,7 +1042,7 @@ export function defineScopeDO(modules, bareOps) {
860
1042
  */
861
1043
  undrainedEventsRead(limit) {
862
1044
  return readUndrainedOutbox((offset, count) => this.sql
863
- .exec(`SELECT * FROM _substrat_outbox WHERE drained_at IS NULL ORDER BY id LIMIT ? OFFSET ?`, count, offset)
1045
+ .exec(`SELECT * FROM _substrat_outbox WHERE drained_at IS NULL AND ${emittedHere()} ORDER BY id LIMIT ? OFFSET ?`, count, offset)
864
1046
  .toArray(), limit);
865
1047
  }
866
1048
  /**
@@ -950,25 +1132,76 @@ export function defineScopeDO(modules, bareOps) {
950
1132
  * pair check), and a stray role row can no longer make a scope serve a tenant it was never
951
1133
  * provisioned for.
952
1134
  *
953
- * A scope with NO receipt was provisioned before it existed, or has just been restored (a
954
- * dump's receipt is dropped on import: it describes the scope the dump came from). Only
955
- * then is the old inference used: `provisionScopeLocal` projected role definitions under
956
- * the tenant, so a `_substrat_roles` row for it says the same thing. The next projection
957
- * (reconcile, or the restore's repair) writes the receipt, after which the inference is
958
- * never consulted again.
1135
+ * A scope with NO receipt was provisioned before it existed (a load never brings the dump's:
1136
+ * it describes the scope the dump came from, and the store keeps its own, #2016). Only then
1137
+ * is the old inference used: `provisionScopeLocal` projected role definitions under the
1138
+ * tenant, so a `_substrat_roles` row for it says the same thing. The next projection
1139
+ * (reconcile, or the restore's repair) or lifecycle delivery writes the receipt, after which
1140
+ * the inference is never consulted again.
959
1141
  *
960
1142
  * Read without migrating, so asking about a foreign scope leaves its DO as empty as it found it.
961
1143
  */
962
1144
  async servesTenant(tenantId) {
963
- const has = (table) => this.sql.exec(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`, table).toArray().length > 0;
964
- if (has('_substrat_meta')) {
965
- const receipt = this.sql.exec(`SELECT value FROM _substrat_meta WHERE key = 'provisioned_for'`).toArray()[0];
966
- if (receipt)
967
- return receipt.value === tenantId;
1145
+ const { verdict } = this.tenantVerdict(tenantId);
1146
+ return verdict === 'recorded' || verdict === 'inferred';
1147
+ }
1148
+ /**
1149
+ * #2016: what one door into this scope needs from its storage on a CP-less host, in ONE call —
1150
+ * how `tenantId` reads against the scope (`tenantVerdict`), and the lifecycle the platform
1151
+ * delivered (#1713). The coordinator refuses a foreign pair before any guard, handler or store
1152
+ * lookup runs, and reports an `unknown` one it lets through. Read without migrating, like
1153
+ * `servesTenant`.
1154
+ */
1155
+ admission(tenantId) {
1156
+ return { verdict: this.tenantVerdict(tenantId).verdict, lifecycle: readLifecycle(this.switchSql()) };
1157
+ }
1158
+ /**
1159
+ * #1738 / #2016: this scope's tenant against `tenantId` — the one reading behind the served-here
1160
+ * gate, every door's pair check and the lifecycle delivery's back-fill.
1161
+ *
1162
+ * - `recorded`: the `provisioned_for` receipt names `tenantId`.
1163
+ * - `foreign`: the receipt names another tenant, or — on a scope provisioned before receipts
1164
+ * existed — the scope holds role rows and none of them is `tenantId`'s.
1165
+ * - `inferred`: no receipt, and a role row for `tenantId` (what `provisionScopeLocal` projected
1166
+ * before the receipt existed).
1167
+ * - `unknown`: no receipt and no role rows — never provisioned here, or loaded from a world
1168
+ * that keeps its roles elsewhere and not yet repaired. Nothing to hold the pair against.
1169
+ *
1170
+ * `held` is the receipt read, so a refusal can name it. Read without migrating, so asking about
1171
+ * a foreign scope leaves its DO as empty as it found it.
1172
+ */
1173
+ tenantVerdict(tenantId) {
1174
+ const held = this.provisionedFor();
1175
+ if (held !== null)
1176
+ return { verdict: held === tenantId ? 'recorded' : 'foreign', held };
1177
+ if (!this.hasTable('_substrat_roles'))
1178
+ return { verdict: 'unknown', held };
1179
+ if (this.sql.exec('SELECT 1 FROM _substrat_roles WHERE tenant_id = ? LIMIT 1', tenantId).toArray().length > 0) {
1180
+ return { verdict: 'inferred', held };
968
1181
  }
969
- if (!has('_substrat_roles'))
970
- return false;
971
- return this.sql.exec('SELECT 1 FROM _substrat_roles WHERE tenant_id = ? LIMIT 1', tenantId).toArray().length > 0;
1182
+ const anyRole = this.sql.exec('SELECT 1 FROM _substrat_roles LIMIT 1').toArray().length > 0;
1183
+ return { verdict: anyRole ? 'foreign' : 'unknown', held };
1184
+ }
1185
+ /**
1186
+ * #2016: whether a scope with no receipt and no role rows holds state — rather than being a DO
1187
+ * nothing ever provisioned or loaded here. Only such a scope takes its tenant from a lifecycle
1188
+ * delivery: recording one in an empty DO would make `servesTenant` answer for a scope this
1189
+ * deployment does not hold.
1190
+ *
1191
+ * Every provision since #1738 writes the receipt itself (that IS the durable provision marker),
1192
+ * so this only ever judges a scope from before it, or a load from an older platform. A module
1193
+ * with no SQL migrations still leaves spine state, so any of it counts, not migrations alone:
1194
+ * applied migrations, scope tuples (its grants, `system:` ones included), events, schedule state.
1195
+ */
1196
+ holdsData() {
1197
+ return HOLDS_DATA_TABLES.some((table) => this.hasTable(table) && this.sql.exec(`SELECT 1 FROM ${table} LIMIT 1`).toArray().length > 0);
1198
+ }
1199
+ /** #1738: the tenant this scope's `provisioned_for` receipt names, or null; read without migrating. */
1200
+ provisionedFor() {
1201
+ return this.hasTable('_substrat_meta') ? this.metaValue(PROVISIONED_FOR_KEY) : null;
1202
+ }
1203
+ hasTable(name) {
1204
+ return this.sql.exec(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`, name).toArray().length > 0;
972
1205
  }
973
1206
  /** #1705 PR 2: the outbox's insertion mark (`OUTBOX_MARK_SQL`). */
974
1207
  outboxMark() {
@@ -1024,10 +1257,27 @@ export function defineScopeDO(modules, bareOps) {
1024
1257
  (event_id, consumer_module, delivered_at, error, invocation_id)
1025
1258
  VALUES (?, ?, ?, ?, NULL)`, id, moduleId, when, error);
1026
1259
  };
1260
+ // #1901: one line per (event, importing module), as for the scope's own consumers.
1261
+ // The handler runs as the producer's peer, so that is who the line says it ran as.
1262
+ const lines = asyncLinePass();
1263
+ const unitOf = (moduleId, eventId, eventType) => ({
1264
+ kind: 'consumer',
1265
+ tenantId,
1266
+ scopeId,
1267
+ invocationId: asyncInvocationId(null),
1268
+ operation: moduleId,
1269
+ eventType,
1270
+ eventId,
1271
+ attempt: 1,
1272
+ startedAt: Date.now(),
1273
+ principalKind: peerSubject.kind,
1274
+ versionId: this.env.SUBSTRAT_VERSION_ID ?? null,
1275
+ });
1027
1276
  for (const w of batch.withheld) {
1028
1277
  this.sql.exec(IMPORT_RECORD_SQL, w.id, source.scopeId, source.vertical, w.type, w.schemaVersion, w.occurredAt, w.entity.entityType, w.entity.entityId, 0, w.reason, at);
1029
1278
  for (const moduleId of this.crossVertical.modulesImporting(source.vertical, w.type)) {
1030
1279
  deadLetter(w.id, moduleId, at, withheldNote(w.reason, source.vertical));
1280
+ lines.write({ ...unitOf(moduleId, w.id, w.type), outcome: 'dead-lettered' });
1031
1281
  }
1032
1282
  result.withheld += 1;
1033
1283
  }
@@ -1040,14 +1290,17 @@ export function defineScopeDO(modules, bareOps) {
1040
1290
  ran = true;
1041
1291
  if (imp.schemaVersion !== e.schemaVersion) {
1042
1292
  deadLetter(e.id, imp.moduleId, at, withheldNote('version', source.vertical));
1293
+ lines.write({ ...unitOf(imp.moduleId, e.id, e.type), outcome: 'dead-lettered' });
1043
1294
  result.deadLettered += 1;
1044
1295
  continue;
1045
1296
  }
1046
1297
  const { hops: _hops, ...fact } = e;
1047
1298
  const event = structuredClone({ ...fact, source });
1299
+ const unit = unitOf(imp.moduleId, e.id, e.type);
1048
1300
  this.causedBy = e.id;
1301
+ this.unitInvocationId = unit.invocationId;
1049
1302
  try {
1050
- await this.ctx.storage.transaction(async () => {
1303
+ await this.revision.transaction(async () => {
1051
1304
  // As the producer's principal: real checks against the grants this vertical's
1052
1305
  // `peers` gave it; its emits carry `{ vertical, scope }` and what they passed.
1053
1306
  await imp.handler(this.importContext(tenantId, scopeId, peerSubject), event);
@@ -1055,14 +1308,17 @@ export function defineScopeDO(modules, bareOps) {
1055
1308
  VALUES (?, ?, ?, NULL)`, e.id, imp.moduleId, new Date().toISOString());
1056
1309
  });
1057
1310
  result.delivered += 1;
1311
+ lines.write({ ...unit, outcome: 'delivered' });
1058
1312
  }
1059
1313
  catch (err) {
1060
1314
  // Dead-letter (v0), outside the rolled-back transaction.
1061
1315
  deadLetter(e.id, imp.moduleId, new Date().toISOString(), String(err));
1062
1316
  result.deadLettered += 1;
1317
+ lines.write({ ...unit, outcome: 'dead-lettered', error: err });
1063
1318
  }
1064
1319
  finally {
1065
1320
  this.causedBy = null;
1321
+ this.unitInvocationId = null;
1066
1322
  }
1067
1323
  }
1068
1324
  if (!ran)
@@ -1070,6 +1326,7 @@ export function defineScopeDO(modules, bareOps) {
1070
1326
  }
1071
1327
  this.sql.exec(IMPORT_CURSOR_ADVANCE_SQL, source.scopeId, source.vertical, batch.next, at);
1072
1328
  result.cursor = batch.next;
1329
+ lines.end();
1073
1330
  await this.settleCommitted(tenantId, scopeId, liveSince, null);
1074
1331
  return result;
1075
1332
  });
@@ -1320,7 +1577,7 @@ export function defineScopeDO(modules, bareOps) {
1320
1577
  * and the re-assert in which a sweep could run the grants just seated.
1321
1578
  */
1322
1579
  async seatTuples(tuples, switchOff) {
1323
- return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => {
1580
+ return this.queue.enqueue(() => this.revision.transactionSync(() => {
1324
1581
  for (const t of tuples) {
1325
1582
  const seat = seatScopeTuple(t.subject, t.relation, t.object, t.expires_at);
1326
1583
  this.sql.exec(seat.sql, ...seat.params);
@@ -1445,21 +1702,33 @@ export function defineScopeDO(modules, bareOps) {
1445
1702
  * `vertical.honoured`: an old DO that ignored this argument would run the call as that
1446
1703
  * placeholder, and the coordinator refuses a success without the acknowledgement.
1447
1704
  */
1448
- verticalCaller) {
1705
+ verticalCaller,
1706
+ /**
1707
+ * #1834: set on a system-door call — the instance whose state read the door's gate
1708
+ * consulted the rewind hold against. On any other instance nothing runs and the answer is
1709
+ * `systemDoorMoved` instead, because a PITR restore always restarts this object, so another
1710
+ * instance may be rewound storage the gate never saw. The reply carries `systemDoor.honoured`.
1711
+ */
1712
+ systemDoorInstance) {
1449
1713
  if (!failureEnvelope) {
1450
1714
  // Legacy path, byte-for-byte what it was: rewrapped so a non-plain error (a
1451
1715
  // ZodError, whose `message` is a getter) still arrives with its message.
1452
1716
  try {
1453
- return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession, verticalCaller);
1717
+ return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession, verticalCaller, systemDoorInstance);
1454
1718
  }
1455
1719
  catch (err) {
1720
+ if (err instanceof SystemDoorMovedError)
1721
+ return { result: undefined, platformRequests: 0, ...SYSTEM_DOOR_MOVED };
1456
1722
  throw toRpcError(err);
1457
1723
  }
1458
1724
  }
1459
1725
  try {
1460
- return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession, verticalCaller);
1726
+ return await this.invokeOrThrow(operation, input, principal, tenantId, scopeId, connectionId, requiredEntitlement, systemModuleId, invokeOptions, impersonation, capabilitySession, verticalCaller, systemDoorInstance);
1461
1727
  }
1462
1728
  catch (err) {
1729
+ // #1834: the pin missed — an answer, never a failure an operation could have produced.
1730
+ if (err instanceof SystemDoorMovedError)
1731
+ return { result: undefined, platformRequests: 0, ...SYSTEM_DOOR_MOVED };
1463
1732
  // The ONE place the error keeps its structure: flattened here, rebuilt by the
1464
1733
  // coordinator. `toRpcError` is not applied — that exists to make a throw
1465
1734
  // survivable, and this is not a throw.
@@ -1473,7 +1742,9 @@ export function defineScopeDO(modules, bareOps) {
1473
1742
  /** #1672: a capability session's hash. See `invoke` above. */
1474
1743
  capabilitySession,
1475
1744
  /** #1706: the calling vertical, as the platform named it. See `invoke` above. */
1476
- verticalCaller) {
1745
+ verticalCaller,
1746
+ /** #1834: the instance the system door's gate read. See `invoke` above. */
1747
+ systemDoorInstance) {
1477
1748
  await this.ensureMigrations();
1478
1749
  const handler = this.operations.get(operation);
1479
1750
  // `not_found`, not a bare throw (#113): every vertical hand-matched this message
@@ -1556,6 +1827,9 @@ export function defineScopeDO(modules, bareOps) {
1556
1827
  // one call. Same placement as the SQLite adapter's actor task, for this reason.
1557
1828
  this.invocationId = invokeOptions?.invocationId ?? null;
1558
1829
  try {
1830
+ // #1834: before anything opens. This instance is the storage the call meets, for the
1831
+ // whole call: a restore restarts the object, so it cannot move under a running body.
1832
+ const systemDoor = this.assertSystemDoor(systemModuleId, systemDoorInstance);
1559
1833
  // #1672: the capability session, resolved on EVERY call and here — inside the queued
1560
1834
  // body, the one region where this call holds the DO to itself — so nothing can
1561
1835
  // revoke between this read and the transaction. Refuses a stale session, a revoked
@@ -1629,8 +1903,8 @@ export function defineScopeDO(modules, bareOps) {
1629
1903
  // commit together, or a throw (from either) rolls domain writes AND
1630
1904
  // emitted events back as one — verified across `await` in workerd.
1631
1905
  try {
1632
- await this.ctx.storage.transaction(async () => {
1633
- const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemModuleId, signals, impersonation, operation, capabilityId, minted, undefined, peerSubject);
1906
+ await this.revision.transaction(async () => {
1907
+ const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemDoor, signals, impersonation, operation, capabilityId, minted, undefined, peerSubject);
1634
1908
  // #116: a retry is answered from the recording, and nothing else runs
1635
1909
  // — not the guards, not the handler, not the permission check inside
1636
1910
  // it. Keyed by SUBJECT, so a caller only ever reaches its own
@@ -1698,6 +1972,7 @@ export function defineScopeDO(modules, bareOps) {
1698
1972
  impersonation: { honoured: true },
1699
1973
  ...(capabilitySession !== undefined ? { capability: { honoured: true } } : {}),
1700
1974
  ...(verticalCaller !== undefined ? { vertical: { honoured: true } } : {}),
1975
+ ...(systemDoorInstance !== undefined ? { systemDoor: { honoured: true } } : {}),
1701
1976
  ...(idempotencyKey !== undefined
1702
1977
  ? { idempotency: { keyHonoured: true, replayed } }
1703
1978
  : {}),
@@ -1762,6 +2037,8 @@ export function defineScopeDO(modules, bareOps) {
1762
2037
  ...(capabilitySession !== undefined ? { capability: { honoured: true } } : {}),
1763
2038
  // #1706: the same, for the peer door.
1764
2039
  ...(verticalCaller !== undefined ? { vertical: { honoured: true } } : {}),
2040
+ // #1834: the same, for the system door's instance pin.
2041
+ ...(systemDoorInstance !== undefined ? { systemDoor: { honoured: true } } : {}),
1765
2042
  // The acknowledgement the coordinator's skew check reads (#116), on the
1766
2043
  // same reasoning as `ifMatchChecked` below and with a sharper failure: a
1767
2044
  // DO too old to know about keys would EXECUTE THE OPERATION AGAIN and
@@ -2111,7 +2388,7 @@ export function defineScopeDO(modules, bareOps) {
2111
2388
  // the reason it is read there: that is where this call holds the DO to itself.
2112
2389
  const liveSince = this.liveHighWaterMark();
2113
2390
  try {
2114
- await this.ctx.storage.transaction(async () => {
2391
+ await this.revision.transaction(async () => {
2115
2392
  const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, undefined, undefined, undefined, 'attachments.upload');
2116
2393
  assertAllowed(await ctx.check(gate.write, parsed.entity));
2117
2394
  this.sql.exec(`INSERT INTO _substrat_attachments
@@ -2203,8 +2480,19 @@ export function defineScopeDO(modules, bareOps) {
2203
2480
  return record;
2204
2481
  }
2205
2482
  /** Authorize a job's read as its registered module, never as a person. */
2206
- async systemAttachmentAuthorize(attachmentId, moduleId, tenantId, scopeId) {
2483
+ async systemAttachmentAuthorize(attachmentId, moduleId, tenantId, scopeId,
2484
+ /** #1834: the instance the system door's gate read; see `invoke`. */
2485
+ systemDoorInstance) {
2207
2486
  await this.ensureMigrations();
2487
+ let systemDoor;
2488
+ try {
2489
+ systemDoor = this.assertSystemDoor(moduleId, systemDoorInstance);
2490
+ }
2491
+ catch (err) {
2492
+ if (err instanceof SystemDoorMovedError)
2493
+ return SYSTEM_DOOR_MOVED;
2494
+ throw toRpcError(err);
2495
+ }
2208
2496
  if (!this.modules.has(moduleId)) {
2209
2497
  throw toRpcError(substratError('not_found', `module not registered in this scope: ${moduleId}`));
2210
2498
  }
@@ -2213,7 +2501,7 @@ export function defineScopeDO(modules, bareOps) {
2213
2501
  return null;
2214
2502
  const gate = this.attachmentGate(record.entity.entityType);
2215
2503
  try {
2216
- const ctx = this.operationContext(this.systemPrincipal, tenantId, scopeId, undefined, undefined, moduleId, undefined, undefined, 'attachments.open');
2504
+ const ctx = this.operationContext(this.systemPrincipal, tenantId, scopeId, undefined, undefined, systemDoor, undefined, undefined, 'attachments.open');
2217
2505
  assertAllowed(await ctx.check(gate.read, record.entity));
2218
2506
  }
2219
2507
  catch (err) {
@@ -2235,7 +2523,7 @@ export function defineScopeDO(modules, bareOps) {
2235
2523
  // #938: same mark-then-settle pair as the upload path above.
2236
2524
  const liveSince = this.liveHighWaterMark();
2237
2525
  try {
2238
- await this.ctx.storage.transaction(async () => {
2526
+ await this.revision.transaction(async () => {
2239
2527
  const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, undefined, undefined, undefined, 'attachments.remove');
2240
2528
  assertAllowed(await ctx.check(gate.write, record.entity));
2241
2529
  this.sql.exec('DELETE FROM _substrat_attachments WHERE id = ?', attachmentId);
@@ -2301,7 +2589,7 @@ export function defineScopeDO(modules, bareOps) {
2301
2589
  */
2302
2590
  async attachmentTextRecord(attachmentId, outcome) {
2303
2591
  await this.ensureMigrations();
2304
- return this.queue.enqueue(async () => this.ctx.storage.transactionSync(() => recordAttachmentText(doSpineSql(this.sql), attachmentId, outcome, new Date().toISOString())));
2592
+ return this.queue.enqueue(async () => this.revision.transactionSync(() => recordAttachmentText(doSpineSql(this.sql), attachmentId, outcome, new Date().toISOString())));
2305
2593
  }
2306
2594
  // -- attachments through a capability (#1686) -------------------------------
2307
2595
  // The coordinator hashed the session token; only the hash arrives. Each verb resolves it
@@ -2405,6 +2693,41 @@ export function defineScopeDO(modules, bareOps) {
2405
2693
  async systemScheduleState(moduleId) {
2406
2694
  return systemScheduleState(this.switchSql(), moduleId, new Date().toISOString());
2407
2695
  }
2696
+ /**
2697
+ * #1834: the system door's state read — `systemScheduleState`, and the instance that
2698
+ * answered it. The door consults the rewind hold after this read and pins every call it
2699
+ * then makes to this instance (`assertSystemDoor`).
2700
+ */
2701
+ async systemDoorState(moduleId) {
2702
+ return { state: await this.systemScheduleState(moduleId), instance: this.instanceId };
2703
+ }
2704
+ /**
2705
+ * #1834: the system door's check on this side, and the ONLY maker of the `SystemDoorPass`
2706
+ * the operation context needs to act as `system:<moduleId>`. A call acting as a module must
2707
+ * carry the instance the host's door gate read (refused without one: no door gated it), and
2708
+ * is refused on any other instance. A PITR restore always restarts this object, so a call
2709
+ * landing on a new instance may meet rewound storage whose module the gate never checked
2710
+ * against the rewind hold. Both are refused before anything opens; the moved one is the
2711
+ * module-private `SystemDoorMovedError`, which the RPC answers as `SystemDoorMoved`, so the door
2712
+ * gates again and retries. No module, no pass.
2713
+ */
2714
+ assertSystemDoor(moduleId, expected) {
2715
+ if (moduleId === undefined)
2716
+ return undefined;
2717
+ // Strict, and deliberately so: no pin means no door gated this call, so it is refused rather
2718
+ // than run unchecked. The one innocent caller is a worker a deploy behind, still running host
2719
+ // code from before the pin, during a rolling deploy. Its refusal is transient: the host and this
2720
+ // object ship in the same bundle, so the window lasts as long as the rollout. Nothing ran, and
2721
+ // the reason says "not now" (`SYSTEM_DOOR_WAIT`) to whoever reads it: a schedule fires on the
2722
+ // next pass, and a job run retries on its next drive.
2723
+ if (expected === undefined) {
2724
+ throw substratError('forbidden', `a call acting as module '${moduleId}' reached this scope without passing the system door`, { reason: SYSTEM_DOOR_WAIT });
2725
+ }
2726
+ if (expected !== this.instanceId) {
2727
+ throw new SystemDoorMovedError("the scope restarted after the system door's gate read it; gate it again");
2728
+ }
2729
+ return { moduleId, [SYSTEM_DOOR_PASSED]: true };
2730
+ }
2408
2731
  /**
2409
2732
  * The pre-#1666 read, kept for ONE reason: a coordinator a deploy behind this DO still
2410
2733
  * calls it. It answers the new question, not the old one — the old predicate counted any
@@ -2434,8 +2757,8 @@ export function defineScopeDO(modules, bareOps) {
2434
2757
  * `switchSystemSchedules`, serialized on the queue with every other tuple write and run
2435
2758
  * as one `transactionSync`, so its reads and writes are one unit.
2436
2759
  */
2437
- async switchSystemSchedules(moduleId, scopeId, to, at) {
2438
- const outcome = await this.queue.enqueue(() => this.ctx.storage.transactionSync(() => switchSystemSchedules(this.switchSql(), { moduleId, scopeId, to, at })));
2760
+ async switchSystemSchedules(moduleId, scopeId, to, at, tenantHeld = false) {
2761
+ const outcome = await this.queue.enqueue(() => this.revision.transactionSync(() => switchSystemSchedules(this.switchSql(), { moduleId, scopeId, to, at, tenantHeld })));
2439
2762
  // #1819: the instance that applied it, which the rewind hold's release rule reads.
2440
2763
  return { ...outcome, instance: this.instanceId };
2441
2764
  }
@@ -2450,14 +2773,14 @@ export function defineScopeDO(modules, bareOps) {
2450
2773
  // The consumer's own running imports decide whether the edge exists: read here, in the
2451
2774
  // deployment whose handlers would run, never from the platform's request.
2452
2775
  const imports = this.crossVertical.consumes();
2453
- return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => moveImportCursor(this.switchSql(), { ...input, imports })));
2776
+ return this.queue.enqueue(() => this.revision.transactionSync(() => moveImportCursor(this.switchSql(), { ...input, imports })));
2454
2777
  }
2455
2778
  /**
2456
2779
  * Move one PEER's kill switch on this scope (#1706) — the kernel's `switchPeer`, which is
2457
2780
  * the schedule switch's statement with a `vertical:` subject. Queued, one transaction.
2458
2781
  */
2459
2782
  async switchPeer(vertical, scopeId, to, at) {
2460
- return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => switchPeer(this.switchSql(), { vertical, scopeId, to, at })));
2783
+ return this.queue.enqueue(() => this.revision.transactionSync(() => switchPeer(this.switchSql(), { vertical, scopeId, to, at })));
2461
2784
  }
2462
2785
  /**
2463
2786
  * Does peer `vertical` hold each key at this scope's node now (#1706) — the checker's own
@@ -2581,7 +2904,7 @@ export function defineScopeDO(modules, bareOps) {
2581
2904
  */
2582
2905
  switchHoldOn(scopeId, moduleId, claimIds) {
2583
2906
  this.switchHoldsTable();
2584
- this.ctx.storage.transactionSync(() => {
2907
+ this.revision.transactionSync(() => {
2585
2908
  const seq = this.sql
2586
2909
  .exec(`INSERT INTO _substrat_switch_hold_seq (id, seq) VALUES (1, 1)
2587
2910
  ON CONFLICT (id) DO UPDATE SET seq = seq + 1 RETURNING seq`)
@@ -2606,7 +2929,7 @@ export function defineScopeDO(modules, bareOps) {
2606
2929
  switchHoldClaim(scopeId, moduleIds, claimId, token) {
2607
2930
  this.switchHoldsTable();
2608
2931
  const at = new Date().toISOString();
2609
- this.ctx.storage.transactionSync(() => {
2932
+ this.revision.transactionSync(() => {
2610
2933
  for (const moduleId of moduleIds) {
2611
2934
  this.sql.exec(`INSERT OR IGNORE INTO _substrat_switch_holds (scope_id, module_id, claim_id, state, doomed, held_at)
2612
2935
  SELECT ?, ?, ?, 'pending', NULL, ?
@@ -2626,7 +2949,7 @@ export function defineScopeDO(modules, bareOps) {
2626
2949
  switchHoldJoin(scopeId, moduleId, claimIds) {
2627
2950
  this.switchHoldsTable();
2628
2951
  const at = new Date().toISOString();
2629
- this.ctx.storage.transactionSync(() => {
2952
+ this.revision.transactionSync(() => {
2630
2953
  for (const claimId of claimIds) {
2631
2954
  this.sql.exec(`INSERT OR IGNORE INTO _substrat_switch_holds (scope_id, module_id, claim_id, state, doomed, held_at)
2632
2955
  SELECT scope_id, ?, claim_id, state, doomed, ? FROM _substrat_switch_holds
@@ -2684,7 +3007,7 @@ export function defineScopeDO(modules, bareOps) {
2684
3007
  /** Release these claims on one module, or every claim on the scope when `claimIds` is null. */
2685
3008
  switchHoldRelease(scopeId, moduleId, claimIds) {
2686
3009
  this.switchHoldsTable();
2687
- this.ctx.storage.transactionSync(() => {
3010
+ this.revision.transactionSync(() => {
2688
3011
  if (moduleId === null || claimIds === null) {
2689
3012
  this.sql.exec('DELETE FROM _substrat_switch_holds WHERE scope_id = ?', scopeId);
2690
3013
  this.sql.exec('DELETE FROM _substrat_switch_hold_ons WHERE scope_id = ?', scopeId);
@@ -2721,7 +3044,7 @@ export function defineScopeDO(modules, bareOps) {
2721
3044
  // and the event's `occurredAt` are one fact, and two clock reads could disagree.
2722
3045
  const now = instant.parse(new Date().toISOString());
2723
3046
  let outcome = null;
2724
- await this.ctx.storage.transaction(async () => {
3047
+ await this.revision.transaction(async () => {
2725
3048
  outcome = await exchangeCapability({
2726
3049
  sql: doSpineSql(this.sql),
2727
3050
  now,
@@ -2751,7 +3074,7 @@ export function defineScopeDO(modules, bareOps) {
2751
3074
  */
2752
3075
  async revokeCapabilityAsPlatform(id, actor) {
2753
3076
  await this.ensureMigrations();
2754
- return await this.queue.enqueue(() => this.ctx.storage.transactionSync(() => revokeCapabilityAsPlatform(doSpineSql(this.sql), id, actor, instant.parse(new Date().toISOString()))) ?? null);
3077
+ return await this.queue.enqueue(() => this.revision.transactionSync(() => revokeCapabilityAsPlatform(doSpineSql(this.sql), id, actor, instant.parse(new Date().toISOString()))) ?? null);
2755
3078
  }
2756
3079
  /**
2757
3080
  * The operator's read of this scope's capabilities (#1686). The kernel's one read
@@ -2873,7 +3196,7 @@ export function defineScopeDO(modules, bareOps) {
2873
3196
  // `jobRunInsert`: an `await` between them is an output-gate boundary, and the
2874
3197
  // point of doing this in one RPC is that there is no boundary to be evicted
2875
3198
  // at. Same reason `SCHEDULE_STATE_REBUILD` insists on it.
2876
- this.ctx.storage.transactionSync(() => {
3199
+ this.revision.transactionSync(() => {
2877
3200
  this.sql.exec(`INSERT INTO _substrat_job_runs
2878
3201
  (id, module_id, job, instance, payload, status, cursor, counters, attempts,
2879
3202
  last_error, started_at, updated_at, next_attempt_at, ended_at, subject_id)
@@ -2913,6 +3236,18 @@ export function defineScopeDO(modules, bareOps) {
2913
3236
  ORDER BY id LIMIT ?`, now, afterId ?? null, afterId ?? null, limit)
2914
3237
  .toArray();
2915
3238
  }
3239
+ /**
3240
+ * #1834: the drive's ONE snapshot of due runs — keys only, in the order they became due
3241
+ * (`JOB_RUN_DUE_AT`, then id). Spelled exactly as the pure adapter spells it. `jobRunsDue`
3242
+ * above stays for a coordinator a deploy behind, which still pages by id.
3243
+ */
3244
+ async jobRunsDueKeys(now, max) {
3245
+ return this.sql
3246
+ .exec(`SELECT id, module_id, job FROM _substrat_job_runs
3247
+ WHERE status = 'running' AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
3248
+ ORDER BY ${JOB_RUN_DUE_AT}, id LIMIT ?`, now, max)
3249
+ .toArray();
3250
+ }
2916
3251
  /** The operator read, newest first. */
2917
3252
  async jobRunList(filter) {
2918
3253
  const where = [];
@@ -2973,7 +3308,7 @@ export function defineScopeDO(modules, bareOps) {
2973
3308
  // `transactionSync` with both statements inline — NOT `await
2974
3309
  // this.jobRunPatch(...)` then the delete. The await is an output-gate
2975
3310
  // boundary, which is precisely the gap this method exists to close.
2976
- this.ctx.storage.transactionSync(() => {
3311
+ this.revision.transactionSync(() => {
2977
3312
  this.sql.exec(JOB_RUN_PATCH_SQL, patch.status, patch.cursor, patch.counters, patch.attempts, patch.lastError, patch.updatedAt, patch.nextAttemptAt, patch.endedAt, id);
2978
3313
  this.sql.exec('DELETE FROM _substrat_job_steps WHERE run_id = ?', id);
2979
3314
  });
@@ -2994,7 +3329,15 @@ export function defineScopeDO(modules, bareOps) {
2994
3329
  throw new Error(`unknown guard predicate: '${guard.predicate}' — declared by ${guard.declaredBy} ` +
2995
3330
  `before '${operation}'; no registered module contributes it (operation blocked)`);
2996
3331
  }
2997
- await predicate.handler(ctx, guard.config, input);
3332
+ try {
3333
+ await predicate.handler(ctx, guard.config, input);
3334
+ }
3335
+ catch (err) {
3336
+ // #1745: a guard refusing is recorded after the rollback, as a transition is —
3337
+ // marked here, where which guard threw is still known. Always rethrown.
3338
+ markGuardRefusal(err, guard.predicate, operation);
3339
+ throw err;
3340
+ }
2998
3341
  }
2999
3342
  }
3000
3343
  // -- migrations (port of applyPendingMigrations) --------------------------
@@ -3042,7 +3385,7 @@ export function defineScopeDO(modules, bareOps) {
3042
3385
  if (this.applied.has(key))
3043
3386
  continue;
3044
3387
  try {
3045
- await this.ctx.storage.transaction(async () => {
3388
+ await this.revision.transaction(async () => {
3046
3389
  const already = this.sql
3047
3390
  .exec('SELECT 1 FROM _substrat_migrations WHERE module_id = ? AND version = ?', moduleId, migration.version)
3048
3391
  .toArray()[0];
@@ -3052,8 +3395,16 @@ export function defineScopeDO(modules, bareOps) {
3052
3395
  // #1898: a migration runs on this DO's own handle, not `ctx.sql`, so the
3053
3396
  // spine guard's REFERENCES rule is applied here.
3054
3397
  assertNoSpineReference(migration.sql, `migration ${key}`);
3055
- for (const stmt of splitSqlStatements(migration.sql)) {
3056
- this.sql.exec(stmt);
3398
+ // #1722: not counted per statement, so `total_changes()` measures the migration
3399
+ // alone. The journal row below is a write, and advances the revision once.
3400
+ this.revisionSuspended = true;
3401
+ try {
3402
+ for (const stmt of splitSqlStatements(migration.sql)) {
3403
+ this.sql.exec(stmt);
3404
+ }
3405
+ }
3406
+ finally {
3407
+ this.revisionSuspended = false;
3057
3408
  }
3058
3409
  const after = this.sql.exec('SELECT total_changes() AS n').toArray()[0].n;
3059
3410
  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);
@@ -3101,7 +3452,7 @@ export function defineScopeDO(modules, bareOps) {
3101
3452
  .exec(`SELECT o.* FROM _substrat_outbox o
3102
3453
  LEFT JOIN _substrat_deliveries d
3103
3454
  ON d.event_id = o.id AND d.consumer_module = ?
3104
- WHERE o.type = ?
3455
+ WHERE o.type = ? AND ${emittedHere('o.')}
3105
3456
  AND (d.event_id IS NULL
3106
3457
  OR (d.next_attempt_at IS NOT NULL AND d.next_attempt_at <= ?))
3107
3458
  ORDER BY o.id`, deliveryId, eventType, new Date().toISOString())
@@ -3118,6 +3469,87 @@ export function defineScopeDO(modules, bareOps) {
3118
3469
  }
3119
3470
  return { events, undecodable };
3120
3471
  }
3472
+ /**
3473
+ * Whether this scope is a copy (#2005) — the classification on its `_substrat_copy_origin` row
3474
+ * (#2009), which only the directory's word sets, not a load's events mark. A CP-less
3475
+ * coordinator has no directory to read a scope's kind from, so this is how it holds a copy's
3476
+ * executor deliveries inert; a coordinator with a directory asks that instead.
3477
+ */
3478
+ isCopy() {
3479
+ return this.sql.exec(IS_COPY_SQL).toArray().length > 0;
3480
+ }
3481
+ /** The lifecycle the platform last delivered to this scope (#1713), or null for none. */
3482
+ lifecycle() {
3483
+ return readLifecycle(this.switchSql());
3484
+ }
3485
+ /**
3486
+ * Store a lifecycle the platform delivered (#1713, `writeLifecycle`): kept only when it is not
3487
+ * older than the one held. Bookkeeping, like the copy marker: it changes what runs here, not
3488
+ * the scope's data, so it does not advance the write revision a carry fences on.
3489
+ */
3490
+ setLifecycle(next,
3491
+ /** #2016: the tenant the directory delivered this lifecycle for. A scope it is foreign to
3492
+ * (`tenantVerdict`) refuses the delivery untouched; one provisioned before the receipt
3493
+ * existed, whose role rows name this tenant, records it — the back-fill. */
3494
+ tenantId) {
3495
+ const { verdict, held } = tenantId === undefined ? { verdict: null, held: null } : this.tenantVerdict(tenantId);
3496
+ if (verdict === 'foreign') {
3497
+ return { refused: 'tenant', message: `lifecycle delivery ${foreignTenant(held, tenantId)}` };
3498
+ }
3499
+ // The back-fill: a scope provisioned before the receipt (its role rows agree), or one whose
3500
+ // tenant nothing recorded although it holds data (a load from a world that keeps its roles
3501
+ // elsewhere). The directory's word records it; a DO never provisioned here records nothing.
3502
+ const backfill = verdict === 'inferred' || (verdict === 'unknown' && this.holdsData());
3503
+ let out;
3504
+ this.revision.transactionSync(() => {
3505
+ // A write the carry fences on, not bookkeeping: it happens once per legacy scope, and
3506
+ // over-counting only refuses a restore that could have landed. A carried-away copy takes
3507
+ // no write at all, so it is left to the next projection.
3508
+ if (backfill && !this.carriedAwayCopy) {
3509
+ this.sql.exec(`INSERT INTO _substrat_meta (key, value) VALUES (?, ?)`, PROVISIONED_FOR_KEY, tenantId);
3510
+ }
3511
+ out = { ...this.revision.bookkeeping(() => writeLifecycle(this.switchSql(), next)), tenantRecorded: this.provisionedFor() !== null };
3512
+ });
3513
+ return out;
3514
+ }
3515
+ /** Mark this scope a copy (#2005, `markCopyOrigin`): the repair of a copy that predates the
3516
+ * marker. Answers whether this call stamped it; a store that already reads as a copy is left
3517
+ * as it is, and a load's events mark is never moved (#2009). */
3518
+ markCopy() {
3519
+ let marked = false;
3520
+ this.revision.transactionSync(() => {
3521
+ marked = this.revision.bookkeeping(() => markCopyOrigin(this.switchSql(), new Date().toISOString()));
3522
+ });
3523
+ return marked;
3524
+ }
3525
+ /**
3526
+ * Clear a mistaken copy classification (#2005, `clearCopyMarker`); a load's events mark stays,
3527
+ * so copied work still never runs here (#2009). A
3528
+ * clear is a write like any other (Codex #2008 r11), never bookkeeping: it lets the store run
3529
+ * work a copy holds inert, so it advances the write revision, and a carry that exported
3530
+ * before it cannot wipe the repaired store. That carry's wipe keeps it instead.
3531
+ */
3532
+ clearCopyMark(
3533
+ /** The store the caller means (Codex #2008 r12–r13): the platform's reconcile of the store a
3534
+ * carry landed names that carry's load stamp and the revision it read, so a store another
3535
+ * load has replaced since (a governed restore, whose classification is its own) is refused,
3536
+ * compared here, in the clear's own transaction. Absent for staff's correction. */
3537
+ expect) {
3538
+ return this.revision.transactionSync(() => {
3539
+ const from = this.metaValue(WRITE_REVISION_KEY);
3540
+ if (expect && (this.metaValue(LOAD_STAMP_KEY) !== expect.loadStamp || from !== expect.revision)) {
3541
+ return 'changed';
3542
+ }
3543
+ const outcome = clearCopyMarker(this.switchSql());
3544
+ // The clear's own revisions, so a carry's refused wipe can tell that this clear, and
3545
+ // nothing else, is what changed here since its export (`COPY_MARK_CLEARED_KEY`).
3546
+ if (outcome === 'cleared') {
3547
+ this.sql.exec(`INSERT INTO _substrat_meta (key, value) VALUES (?, ?)
3548
+ ON CONFLICT (key) DO UPDATE SET value = excluded.value`, COPY_MARK_CLEARED_KEY, JSON.stringify({ from, to: this.metaValue(WRITE_REVISION_KEY) }));
3549
+ }
3550
+ return outcome;
3551
+ });
3552
+ }
3121
3553
  /**
3122
3554
  * The same read as a bare list of events, for a coordinator deployed before
3123
3555
  * `pendingExecutorDeliveries` (#1636). An undecodable row is left out rather than
@@ -3362,7 +3794,7 @@ export function defineScopeDO(modules, bareOps) {
3362
3794
  let result;
3363
3795
  const rollback = new Error('read-only console rollback');
3364
3796
  try {
3365
- await this.ctx.storage.transaction(async () => {
3797
+ await this.revision.transaction(async () => {
3366
3798
  const cursor = this.sql.exec(stmt);
3367
3799
  const columns = cursor.columnNames;
3368
3800
  const rows = [];
@@ -3442,12 +3874,32 @@ export function defineScopeDO(modules, bareOps) {
3442
3874
  return defs.map(({ name, sql }) => {
3443
3875
  // Raw positional rows, cells as-is (blobs kept as bytes, not nulled like a UI
3444
3876
  // read) so the dump reloads faithfully. The name is from the live schema.
3445
- const cursor = this.sql.exec(`SELECT * FROM "${name}"`);
3877
+ // #1722: the load stamp and the write revision describe THIS store, so they never leave
3878
+ // in a dump (`exportDumpStamped` hands the stamp over beside one); every load writes its own.
3879
+ const cursor = name === '_substrat_meta'
3880
+ ? this.sql.exec(`SELECT * FROM "${name}" WHERE key NOT IN (${STORE_LOCAL_META_KEYS.map(() => '?').join(', ')})`, ...STORE_LOCAL_META_KEYS)
3881
+ : this.sql.exec(`SELECT * FROM "${name}"`);
3446
3882
  const columns = cursor.columnNames;
3447
3883
  const rows = Array.from(cursor.raw(), (row) => row);
3448
3884
  return { name, ddl: sql, columns, rows };
3449
3885
  });
3450
3886
  }
3887
+ /**
3888
+ * `exportDump`, with the store's load stamp read in the same call (#1722): the stamp a
3889
+ * carry's fenced wipe of this copy later expects. A store no load has stamped gets one now,
3890
+ * so the stamp handed out is present exactly while nothing has been loaded here since. One
3891
+ * synchronous method, so no load can land between the dump and the stamp.
3892
+ */
3893
+ exportDumpStamped() {
3894
+ let stamp = this.loadStamp();
3895
+ // A wiped copy takes no write, a stamp included; the carry refuses its tombstone anyway.
3896
+ if (!stamp && !this.carriedAwayCopy) {
3897
+ stamp = ulid();
3898
+ this.sql.exec(`INSERT INTO _substrat_meta (key, value) VALUES (?, ?)`, LOAD_STAMP_KEY, stamp);
3899
+ }
3900
+ // Read after the stamp's own write, so it is the revision the store holds as it is dumped.
3901
+ return { tables: this.exportDump(), loadStamp: stamp, revision: this.metaValue(WRITE_REVISION_KEY) };
3902
+ }
3451
3903
  /**
3452
3904
  * Load a dump into this (freshly-provisioned) scope — the write half of the fork
3453
3905
  * (host.ts importScope). Drop-then-replay: the provisioning schema is wiped and rebuilt,
@@ -3529,6 +3981,9 @@ export function defineScopeDO(modules, bareOps) {
3529
3981
  // every legacy row — a past run's id cannot be recovered afterwards, exactly
3530
3982
  // as the other #1525 columns above argued.
3531
3983
  'ALTER TABLE _substrat_schedule_state ADD COLUMN invocation_id TEXT',
3984
+ // #2009: the copy classification on a scope DO built before it (NULL reads as a copy;
3985
+ // see `COPY_ORIGIN_DDL`).
3986
+ 'ALTER TABLE _substrat_copy_origin ADD COLUMN is_copy INTEGER',
3532
3987
  ]) {
3533
3988
  try {
3534
3989
  this.sql.exec(alter);
@@ -3552,6 +4007,14 @@ export function defineScopeDO(modules, bareOps) {
3552
4007
  // both adapters by the query-plan test rather than by that gate.
3553
4008
  this.sql.exec('CREATE INDEX IF NOT EXISTS _substrat_outbox_invocation ON _substrat_outbox (invocation_id, id)');
3554
4009
  this.ensureScheduleStateKind();
4010
+ this.ensureRefusalsAdmitGuards();
4011
+ }
4012
+ /**
4013
+ * #1745: `_substrat_refusals`, rebuilt to admit a guard row (a nullable `from_state`, plus
4014
+ * `guard` and `reason`) on a scope DO whose table predates it.
4015
+ */
4016
+ ensureRefusalsAdmitGuards() {
4017
+ this.rebuildIfStale('_substrat_refusals', refusalsAdmitGuards, REFUSALS_REBUILD);
3555
4018
  }
3556
4019
  /**
3557
4020
  * #1288: `_substrat_schedule_state`, rebuilt with `kind` in its key on a scope DO
@@ -3563,23 +4026,31 @@ export function defineScopeDO(modules, bareOps) {
3563
4026
  * the stored DDL is the one probe both adapters can make.
3564
4027
  */
3565
4028
  ensureScheduleStateKind() {
4029
+ this.rebuildIfStale('_substrat_schedule_state', scheduleStateHasKind, SCHEDULE_STATE_REBUILD);
4030
+ }
4031
+ /**
4032
+ * Run a kernel create-copy-drop-rename `script` over `table` when the stored DDL fails
4033
+ * `isCurrent` — the one shape `ensureScheduleStateKind` and `ensureRefusalsAdmitGuards`
4034
+ * share, so the pure adapter's twin rebuilds byte-identically.
4035
+ */
4036
+ rebuildIfStale(table, isCurrent, script) {
3566
4037
  const row = this.sql
3567
- .exec(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?`, '_substrat_schedule_state')
4038
+ .exec(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?`, table)
3568
4039
  .toArray()[0];
3569
- if (!row || scheduleStateHasKind(row.sql))
4040
+ if (!row || isCurrent(row.sql))
3570
4041
  return;
3571
- // In a transaction, for the reason the kernel constant spells out: create-copy-
4042
+ // In a transaction, for the reason the kernel constants spell out: create-copy-
3572
4043
  // drop-rename has two intermediate states and both are unrecoverable on the next
3573
4044
  // wake. `transactionSync`, not the async one every operation uses — the DO
3574
4045
  // runtime forbids a manual BEGIN through `sql.exec`, and this body is wholly
3575
4046
  // synchronous, which is the one case the sync API is for (it commits at the
3576
4047
  // first await, and there is none). It also has to be sync because the caller is.
3577
- this.ctx.storage.transactionSync(() => {
3578
- for (const stmt of splitSqlStatements(SCHEDULE_STATE_REBUILD))
4048
+ this.revision.transactionSync(() => {
4049
+ for (const stmt of splitSqlStatements(script))
3579
4050
  this.sql.exec(stmt);
3580
4051
  });
3581
4052
  }
3582
- async importDump(tables, destScopeId, { switchOff, sourceScopeId, exact, } = {}) {
4053
+ async importDump(tables, destScopeId, { switchOff, sourceScopeId, exact, loadStamp, expect, resolveKept, markCopy, provisionedFor, } = {}) {
3583
4054
  // The WHOLE drop-then-replay runs under deferred foreign keys, in one transaction.
3584
4055
  //
3585
4056
  // Two distinct FK hazards, and both need the deferral — the second is why it cannot
@@ -3601,7 +4072,8 @@ export function defineScopeDO(modules, bareOps) {
3601
4072
  // A dump written before indexes were excluded may still carry them; skipped
3602
4073
  // rather than failing a restore over data about to be recomputed.
3603
4074
  // Matched without case, as SQLite resolves a table name (#1883 review).
3604
- const replayable = tables.filter((t) => !isSearchIndexTable(t.name.toLowerCase()));
4075
+ // #1686: a copy into another scope id loads no capability rows; a same-scope restore keeps them.
4076
+ const replayable = capabilitiesForLoad(tables.filter((t) => !isSearchIndexTable(t.name.toLowerCase())), destScopeId, sourceScopeId);
3605
4077
  // The dump is untrusted input (#1143). `SqlStorage.exec` runs every statement
3606
4078
  // in the string it is given, so a `ddl` with anything appended to its CREATE
3607
4079
  // TABLE executed that too — with entirely plain identifiers, which is why no
@@ -3613,74 +4085,138 @@ export function defineScopeDO(modules, bareOps) {
3613
4085
  if (exact && !sourceScopeId) {
3614
4086
  throw substratError('validation_failed', 'restore refused: `exact` needs the scope the dump came from');
3615
4087
  }
3616
- const switched = await this.ctx.storage.transaction(async () => {
3617
- this.sql.exec('PRAGMA defer_foreign_keys = ON');
3618
- // Real tables only; `sqlite_*` internals are auto-managed and un-droppable.
3619
- const existing = this.sql
3620
- .exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB 'sqlite_*'`)
3621
- .toArray();
3622
- // Search index tables are left alone here and rebuilt below (#827): dropping a
3623
- // shadow table directly is an error, and `sqlite_master` order would reach one
3624
- // before its virtual table.
3625
- for (const { name } of existing) {
3626
- if (isSearchIndexTable(name.toLowerCase()))
3627
- continue;
3628
- this.sql.exec(`DROP TABLE IF EXISTS "${name}"`);
4088
+ const switched = await this.revision.transaction(async () => {
4089
+ const before = this.loadMarker();
4090
+ // #1713: the lifecycle the platform delivered here, read before the drops take it.
4091
+ const lifecycleBefore = readLifecycle(this.switchSql());
4092
+ // #2016: this store's own tenant receipt, read before the drops take it. A load never
4093
+ // changes which tenant the scope belongs to: the platform's word for it (`provisionedFor`)
4094
+ // must agree with the receipt held, and is refused before the first drop if it does not.
4095
+ const receiptBefore = this.provisionedFor();
4096
+ if (provisionedFor !== undefined && receiptBefore !== null && receiptBefore !== provisionedFor) {
4097
+ const refused = substratError('conflict', tenantReceiptRefusal(receiptBefore, provisionedFor));
4098
+ tenantRefusals.add(refused);
4099
+ throw refused;
3629
4100
  }
3630
- // A vertical's tables take the dump's own DDL. The spine never does (#1883): a dump
3631
- // that declared a column the checker compares on as COLLATE NOCASE would otherwise
3632
- // decide how this DO's permission checks match. Every `_substrat_*` table is built
3633
- // from KERNEL_DDL instead, and the dump contributes only rows, by column name
3634
- // (`spineRowsInsert`), a missing column taking the kernel's default.
3635
- for (const t of replayable)
3636
- if (!isSpineTable(t.name))
3637
- this.sql.exec(t.ddl);
3638
- // KERNEL_DDL also builds what the dump did not carry (#321). A dump captured from a
3639
- // WORLD that stores some `_substrat_*` tables ELSEWHERE carries only a subset — an
3640
- // `@substrat-run/adapter-sqlite` scope file keeps `_substrat_roles` /
3641
- // `_substrat_tenant_tuples` in its DIRECTORY database, so its per-scope dump omits
3642
- // them, and without them the very next permission check would raise a bare `no such
3643
- // table: _substrat_roles`. Roles land empty here and are re-projected by the
3644
- // restore's repair leg (host.projectRolesLocal) — the spine's job is only to exist so
3645
- // the checker can read it. The column pass follows, for the one outbox index KERNEL_DDL
3646
- // leaves to it.
3647
- for (const stmt of splitSqlStatements(KERNEL_DDL))
3648
- this.sql.exec(stmt);
3649
- this.applySpineColumnAdditions();
3650
- const columnsOf = (name) => doBuiltColumnsOf(this.sql, name);
3651
- assertSpineTablesBuilt(replayable.map((t) => t.name), columnsOf);
3652
- // A spine column this kernel does not know (a dump from a newer one) is kept, as a plain
3653
- // untyped column the checker never reads.
3654
- for (const t of replayable) {
3655
- if (isSpineTable(t.name))
3656
- for (const alter of spineColumnAdditions(t, columnsOf(t.name)))
3657
- this.sql.exec(alter);
4101
+ // #1722 (Codex #2008 r7): a kept copy holds writes nothing else has. No load replaces it
4102
+ // except its own resolution, and that only at the revision the operator acted on.
4103
+ const kept = this.metaValue(KEPT_DIVERGENT_KEY);
4104
+ if (resolveKept) {
4105
+ if (!kept)
4106
+ throw substratError('precondition_failed', 'no kept copy here to resolve; nothing was loaded');
4107
+ if (before.revision !== resolveKept.revision ||
4108
+ (resolveKept.loadStamp !== undefined && before.loadStamp !== resolveKept.loadStamp)) {
4109
+ throw substratError('precondition_failed', 'the kept copy changed since it was read; nothing was loaded');
4110
+ }
3658
4111
  }
3659
- for (const t of replayable) {
3660
- const insert = dumpRowsInsert(t, columnsOf);
3661
- for (const row of t.rows)
3662
- this.sql.exec(insert, ...row);
4112
+ else if (kept) {
4113
+ throw substratError('conflict', KEPT_COPY_REFUSAL);
3663
4114
  }
3664
- // #1738: a dump's `provisioned_for` names the scope (and tenant) it was captured from,
3665
- // not this one. Dropped, so a restore never carries a receipt over; the repair
3666
- // projection that follows writes this scope's own.
3667
- this.sql.exec(`DELETE FROM _substrat_meta WHERE key = 'provisioned_for'`);
3668
- // #1575: attachment text is not in a dump, so the load left it as it was. Drop the
3669
- // text of attachments the dump did not bring back, and queue extraction for those
3670
- // it brought back without text — the bytes decide what that run finds.
3671
- reconcileAttachmentText(doSpineSql(this.sql), ulid, new Date().toISOString());
3672
- // Re-point the restored grants at THIS scope (after the spine exists, so a dump
3673
- // that carried no tuples table still finds one here).
3674
- if (destScopeId) {
3675
- this.rewriteScopeTuples(destScopeId, sourceScopeId && { scopeId: sourceScopeId, exact }, new Date().toISOString());
4115
+ if (expect) {
4116
+ if (before.loadStamp !== expect.loadStamp || (expect.revision !== undefined && before.revision !== expect.revision)) {
4117
+ throw substratError('precondition_failed', 'scope store changed since it was read; nothing was loaded');
4118
+ }
4119
+ }
4120
+ // #1722: the drops take `_substrat_meta` with them; the revision is written back below.
4121
+ this.revisionSuspended = true;
4122
+ try {
4123
+ this.sql.exec('PRAGMA defer_foreign_keys = ON');
4124
+ // Real tables only; `sqlite_*` internals are auto-managed and un-droppable.
4125
+ const existing = this.sql
4126
+ .exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB 'sqlite_*'`)
4127
+ .toArray();
4128
+ // Search index tables are left alone here and rebuilt below (#827): dropping a
4129
+ // shadow table directly is an error, and `sqlite_master` order would reach one
4130
+ // before its virtual table.
4131
+ for (const { name } of existing) {
4132
+ if (isSearchIndexTable(name.toLowerCase()))
4133
+ continue;
4134
+ this.sql.exec(`DROP TABLE IF EXISTS "${name}"`);
4135
+ }
4136
+ // A vertical's tables take the dump's own DDL. The spine never does (#1883): a dump
4137
+ // that declared a column the checker compares on as COLLATE NOCASE would otherwise
4138
+ // decide how this DO's permission checks match. Every `_substrat_*` table is built
4139
+ // from KERNEL_DDL instead, and the dump contributes only rows, by column name
4140
+ // (`spineRowsInsert`), a missing column taking the kernel's default.
4141
+ for (const t of replayable)
4142
+ if (!isSpineTable(t.name))
4143
+ this.sql.exec(t.ddl);
4144
+ // KERNEL_DDL also builds what the dump did not carry (#321). A dump captured from a
4145
+ // WORLD that stores some `_substrat_*` tables ELSEWHERE carries only a subset — an
4146
+ // `@substrat-run/adapter-sqlite` scope file keeps `_substrat_roles` /
4147
+ // `_substrat_tenant_tuples` in its DIRECTORY database, so its per-scope dump omits
4148
+ // them, and without them the very next permission check would raise a bare `no such
4149
+ // table: _substrat_roles`. Roles land empty here and are re-projected by the
4150
+ // restore's repair leg (host.projectRolesLocal) — the spine's job is only to exist so
4151
+ // the checker can read it. The column pass follows, for the one outbox index KERNEL_DDL
4152
+ // leaves to it.
4153
+ for (const stmt of splitSqlStatements(KERNEL_DDL))
4154
+ this.sql.exec(stmt);
4155
+ this.applySpineColumnAdditions();
4156
+ const columnsOf = (name) => doBuiltColumnsOf(this.sql, name);
4157
+ assertSpineTablesBuilt(replayable.map((t) => t.name), columnsOf);
4158
+ // A spine column this kernel does not know (a dump from a newer one) is kept, as a plain
4159
+ // untyped column the checker never reads.
4160
+ for (const t of replayable) {
4161
+ if (isSpineTable(t.name))
4162
+ for (const alter of spineColumnAdditions(t, columnsOf(t.name)))
4163
+ this.sql.exec(alter);
4164
+ }
4165
+ for (const t of replayable) {
4166
+ const insert = dumpRowsInsert(t, columnsOf);
4167
+ for (const row of t.rows)
4168
+ this.sql.exec(insert, ...row);
4169
+ }
4170
+ // #1738: a dump's `provisioned_for` names the scope (and tenant) it was captured from,
4171
+ // not this one, so it is never carried over. #2016: what stays is THIS store's own —
4172
+ // the platform's word when the load carries it (a copy into a fresh scope records its
4173
+ // own tenant here), else the receipt held before the drops. A store that held none
4174
+ // keeps none, and the repair projection that follows writes it.
4175
+ this.sql.exec(`DELETE FROM _substrat_meta WHERE key = ?`, PROVISIONED_FOR_KEY);
4176
+ const receipt = provisionedFor ?? receiptBefore;
4177
+ if (receipt !== null) {
4178
+ this.sql.exec(`INSERT INTO _substrat_meta (key, value) VALUES (?, ?)`, PROVISIONED_FOR_KEY, receipt);
4179
+ }
4180
+ // #1722: whatever stamp an export read here no longer describes this store, so every load
4181
+ // replaces it: with the carry's own, or with none. And the write revision moves on from
4182
+ // where it stood before the drops, so it never goes back.
4183
+ if (loadStamp) {
4184
+ this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES (?, ?)`, LOAD_STAMP_KEY, loadStamp);
4185
+ }
4186
+ else {
4187
+ this.sql.exec(`DELETE FROM _substrat_meta WHERE key = ?`, LOAD_STAMP_KEY);
4188
+ }
4189
+ this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES (?, ?)`, WRITE_REVISION_KEY, String(Number(before.revision ?? 0) + 1));
4190
+ // #1575: attachment text is not in a dump, so the load left it as it was. Drop the
4191
+ // text of attachments the dump did not bring back, and queue extraction for those
4192
+ // it brought back without text — the bytes decide what that run finds.
4193
+ const now = new Date().toISOString();
4194
+ // #1686: nothing the source queued runs in a copy; a return leaves it all queued. BEFORE
4195
+ // the extraction queue below, which is this scope's own work, not the source's.
4196
+ settleCopiedWork(this.switchSql(), destScopeId, sourceScopeId, now);
4197
+ if (markCopy)
4198
+ markCopyOrigin(this.switchSql(), now);
4199
+ // #1713: a return keeps the newer of this store's lifecycle and the dump's, so a backup
4200
+ // from before a suspension does not lift it; a copy keeps only its own.
4201
+ settleLifecycleAfterLoad(this.switchSql(), lifecycleBefore, markCopy === true || isCopyLoad(destScopeId, sourceScopeId));
4202
+ reconcileAttachmentText(doSpineSql(this.sql), ulid, now);
4203
+ // Re-point the restored grants at THIS scope (after the spine exists, so a dump
4204
+ // that carried no tuples table still finds one here).
4205
+ if (destScopeId) {
4206
+ this.rewriteScopeTuples(destScopeId, sourceScopeId && { scopeId: sourceScopeId, exact }, new Date().toISOString());
4207
+ }
4208
+ // #1742: the recorded-off modules go back off INSIDE the replay's transaction, with
4209
+ // the spine and the re-point. A switch that throws rolls the whole restore back, so
4210
+ // the dump's grants never commit live without the switch that should cover them.
4211
+ return destScopeId && switchOff
4212
+ ? switchRecordedOff(this.switchSql(), { scopeId: destScopeId, ...switchOff })
4213
+ : [];
4214
+ }
4215
+ finally {
4216
+ this.revisionSuspended = false;
3676
4217
  }
3677
- // #1742: the recorded-off modules go back off INSIDE the replay's transaction, with
3678
- // the spine and the re-point. A switch that throws rolls the whole restore back, so
3679
- // the dump's grants never commit live without the switch that should cover them.
3680
- return destScopeId && switchOff
3681
- ? switchRecordedOff(this.switchSql(), { scopeId: destScopeId, ...switchOff })
3682
- : [];
3683
4218
  });
4219
+ this.carriedAwayCopy = this.metaValue(CARRIED_AWAY_KEY) !== null;
3684
4220
  // Rebuild the derived search indexes over the rows just loaded (#827). Drop-then-
3685
4221
  // create, so it also repairs an index a dump left stale, and the triggers it
3686
4222
  // recreates are what keep the restored scope in step from here. Skipped for a plan
@@ -3695,6 +4231,18 @@ export function defineScopeDO(modules, bareOps) {
3695
4231
  for (const stmt of splitSqlStatements(searchIndexDdl(plan)))
3696
4232
  this.sql.exec(stmt);
3697
4233
  }
4234
+ // #1335 / #1686: the outbox arrived with the dump, so this DO's event ids resume above it,
4235
+ // as on a wake. A copy's own events then sort above every copied one, which is what
4236
+ // `emittedHere()` relies on. A dump whose top id is no ULID leaves the floor where it was.
4237
+ const top = this.sql.exec('SELECT MAX(id) AS id FROM _substrat_outbox').toArray()[0]?.id;
4238
+ if (top) {
4239
+ try {
4240
+ this.mintEventId.seedFrom(top);
4241
+ }
4242
+ catch {
4243
+ // Not a ULID: nothing this mint could have written, so there is no floor to keep.
4244
+ }
4245
+ }
3698
4246
  // The frontier arrived with the dump — refresh the in-memory applied set so a
3699
4247
  // later migrate() builds on the imported state, not the provisioning state.
3700
4248
  this.applied.clear();
@@ -3728,6 +4276,166 @@ export function defineScopeDO(modules, bareOps) {
3728
4276
  this.lastFailure = null;
3729
4277
  return switched;
3730
4278
  }
4279
+ /**
4280
+ * `importDump` with its two refusals answered as values (#1722): across this RPC a throw
4281
+ * carries only its message, and the host has to tell "the store moved since it was read"
4282
+ * (`changed`, 412) and "this store is a kept copy" (`kept`, 409) from a failure.
4283
+ */
4284
+ async importDumpChecked(tables, destScopeId, opts) {
4285
+ try {
4286
+ return { refused: false, switchedOff: await this.importDump(tables, destScopeId, opts) };
4287
+ }
4288
+ catch (e) {
4289
+ const code = errorCodeOf(e);
4290
+ if (code === 'precondition_failed')
4291
+ return { refused: 'changed' };
4292
+ if (e instanceof Error && tenantRefusals.has(e))
4293
+ return { refused: 'tenant', message: e.message };
4294
+ if (code === 'conflict')
4295
+ return { refused: 'kept' };
4296
+ throw toRpcError(e);
4297
+ }
4298
+ }
4299
+ /** The kept-copy marker (#1722), or null when this store is not one. */
4300
+ keptCopy() {
4301
+ const raw = this.metaValue(KEPT_DIVERGENT_KEY);
4302
+ return raw ? JSON.parse(raw) : null;
4303
+ }
4304
+ /**
4305
+ * Release a kept copy that is the scope's live store after all (#1722, Codex #2008 r8): a carry
4306
+ * that protected its source when the route read elsewhere, while a rollback was about to bind
4307
+ * it. Clears the marker only, fenced on the write revision read; the data stays as it is.
4308
+ */
4309
+ releaseKeptCopy(revision,
4310
+ /** The directory says this scope is not primary (#2005): the released store is marked a copy. */
4311
+ markCopy,
4312
+ /** The load stamp read with `revision` (Codex #2008 r13): a store a load replaced is refused.
4313
+ * A kept copy refuses every load but its own resolution, so this holds by construction too. */
4314
+ loadStamp) {
4315
+ return this.revision.transactionSync(() => {
4316
+ if (!this.metaValue(KEPT_DIVERGENT_KEY))
4317
+ return { refused: 'not-kept' };
4318
+ if (this.metaValue(WRITE_REVISION_KEY) !== revision)
4319
+ return { refused: 'changed' };
4320
+ if (loadStamp !== undefined && this.metaValue(LOAD_STAMP_KEY) !== loadStamp)
4321
+ return { refused: 'changed' };
4322
+ this.sql.exec(`DELETE FROM _substrat_meta WHERE key = ?`, KEPT_DIVERGENT_KEY);
4323
+ if (markCopy)
4324
+ this.revision.bookkeeping(() => markCopyOrigin(this.switchSql(), new Date().toISOString()));
4325
+ return { released: true };
4326
+ });
4327
+ }
4328
+ /**
4329
+ * Discard a kept copy (#1722): the staff resolution that wipes it to the tombstone, only if it
4330
+ * is still one at the write revision the operator acted on. The marker goes with the wipe.
4331
+ */
4332
+ async discardKeptCopy(scopeId, revision, carriedAway,
4333
+ /** The directory says this scope is not primary (#2005): as `wipeCarried`. */
4334
+ markCopy,
4335
+ /** The load stamp read with `revision` (Codex #2008 r13), as `releaseKeptCopy`. */
4336
+ loadStamp) {
4337
+ if (!this.metaValue(KEPT_DIVERGENT_KEY))
4338
+ return { refused: 'not-kept' };
4339
+ try {
4340
+ await this.importDump(carriedAwayDump(carriedAway), scopeId, {
4341
+ sourceScopeId: scopeId,
4342
+ resolveKept: { revision, ...(loadStamp !== undefined ? { loadStamp } : {}) },
4343
+ markCopy: markCopy === true || this.isCopy(), // #2005: as `wipeCarried`
4344
+ });
4345
+ return { discarded: true };
4346
+ }
4347
+ catch (e) {
4348
+ if (errorCodeOf(e) === 'precondition_failed')
4349
+ return { refused: 'changed' };
4350
+ throw toRpcError(e);
4351
+ }
4352
+ }
4353
+ /** What a carry's restore into this store expects to find unchanged (#1722): see `LoadMarker`. */
4354
+ loadMarker() {
4355
+ return { loadStamp: this.metaValue(LOAD_STAMP_KEY), revision: this.metaValue(WRITE_REVISION_KEY) };
4356
+ }
4357
+ /** This store's load stamp (#1722), or null when no load has written one. */
4358
+ loadStamp() {
4359
+ return this.metaValue(LOAD_STAMP_KEY);
4360
+ }
4361
+ metaValue(key) {
4362
+ const row = this.sql.exec(`SELECT value FROM _substrat_meta WHERE key = ?`, key).toArray()[0];
4363
+ return row?.value ?? null;
4364
+ }
4365
+ /**
4366
+ * Wipe the copy a carry left in this script (#1722), only if nothing was loaded here since
4367
+ * the carry exported it. A rollback that restored into this DO in the meantime replaced or
4368
+ * cleared the stamp, and its restore stays. Non-terminal, unlike `destroyStorage`: the store is a load of
4369
+ * the `carriedAway` tombstone, so a later restore into it works as on any scope. False when
4370
+ * refused.
4371
+ */
4372
+ async wipeCarried(scopeId, expectLoadStamp, carriedAway, { expectRevision, protectIfChanged, markCopy, } = {}) {
4373
+ // A store marked now stays one: the copy-origin row goes on the tombstone (the wipe), or
4374
+ // on the copy kept (a refusal), and never counts as a data write (`bookkeeping`).
4375
+ const copy = markCopy === true || this.isCopy();
4376
+ try {
4377
+ await this.importDump(carriedAwayDump(carriedAway), scopeId, {
4378
+ sourceScopeId: scopeId,
4379
+ expect: { loadStamp: expectLoadStamp, ...(expectRevision !== undefined ? { revision: expectRevision } : {}) },
4380
+ // #2005: a copy wiped is still a copy. The drop-and-replay takes the copy-origin row
4381
+ // with everything else, so it is written again, or the tombstone would read as primary.
4382
+ markCopy: copy,
4383
+ });
4384
+ return true;
4385
+ }
4386
+ catch (e) {
4387
+ const code = errorCodeOf(e);
4388
+ // Only on the directory's word: the store's own marker, read before the refused load, may
4389
+ // have been cleared by staff since (Codex #2008 r12), and marking it again would undo that.
4390
+ if (markCopy === true) {
4391
+ this.revision.transactionSync(() => this.revision.bookkeeping(() => markCopyOrigin(this.switchSql(), carriedAway.at)));
4392
+ }
4393
+ if (code === 'conflict') {
4394
+ // Already a kept copy, and the scope has been carried away from it again (Codex #2008
4395
+ // r9). That is recorded on the marker, which is a write: it advances the revision, so a
4396
+ // release (staff or a carry's auto-release) that read the revision before this, when the
4397
+ // scope still routed here, fails its compare-and-set rather than clearing the marker of
4398
+ // a copy the scope has just left.
4399
+ this.revision.transactionSync(() => {
4400
+ const raw = this.metaValue(KEPT_DIVERGENT_KEY);
4401
+ if (!raw)
4402
+ return;
4403
+ const kept = { ...JSON.parse(raw), leftAgain: { to: carriedAway.to, at: carriedAway.at } };
4404
+ this.sql.exec(`UPDATE _substrat_meta SET value = ? WHERE key = ?`, JSON.stringify(kept), KEPT_DIVERGENT_KEY);
4405
+ });
4406
+ return false;
4407
+ }
4408
+ if (code !== 'precondition_failed')
4409
+ throw toRpcError(e);
4410
+ // Refused. When nothing was LOADED here since the export (the stamp is the one read) but
4411
+ // the store was WRITTEN (the revision moved), the copy holds a write the carry never
4412
+ // copied: protect it in the store itself, decided again inside its own transaction.
4413
+ this.revision.transactionSync(() => {
4414
+ const now = this.loadMarker();
4415
+ const writtenSince = expectRevision !== undefined && now.loadStamp === expectLoadStamp && now.revision !== expectRevision;
4416
+ const changedSince = now.loadStamp !== expectLoadStamp || (expectRevision !== undefined && now.revision !== expectRevision);
4417
+ // A wiped copy holds nothing to protect (and takes no write, the marker included).
4418
+ if (this.carriedAwayCopy || this.metaValue(KEPT_DIVERGENT_KEY))
4419
+ return;
4420
+ if (!writtenSince && !(protectIfChanged && changedSince))
4421
+ return;
4422
+ const kept = { carriedTo: carriedAway.to, keptAt: carriedAway.at, revision: now.revision };
4423
+ this.sql.exec(`INSERT INTO _substrat_meta (key, value) VALUES (?, ?)`, KEPT_DIVERGENT_KEY, JSON.stringify(kept));
4424
+ // Codex #2008 r12: changed ONLY by a staff clear of the copy marker — the clear's own
4425
+ // revisions span exactly the export's and the store's now — so the data is what the carry
4426
+ // copied, and only the fresher classification is not. Recorded on the marker with the
4427
+ // revision this keep committed at (the same run: no second bump), which the platform's
4428
+ // discard is fenced on once it has brought the clear to where the scope runs.
4429
+ const cleared = this.metaValue(COPY_MARK_CLEARED_KEY);
4430
+ const span = cleared ? JSON.parse(cleared) : null;
4431
+ if (writtenSince && span && span.from === expectRevision && span.to === now.revision) {
4432
+ const settled = { ...kept, clearedOnly: { revision: this.metaValue(WRITE_REVISION_KEY) } };
4433
+ this.sql.exec(`UPDATE _substrat_meta SET value = ? WHERE key = ?`, JSON.stringify(settled), KEPT_DIVERGENT_KEY);
4434
+ }
4435
+ });
4436
+ return false;
4437
+ }
4438
+ }
3731
4439
  /**
3732
4440
  * Re-point scope-level tuples at the scope they now live in.
3733
4441
  *
@@ -3874,6 +4582,13 @@ export function defineScopeDO(modules, bareOps) {
3874
4582
  * later alarm-driven drain with a call it had nothing to do with.
3875
4583
  */
3876
4584
  invocationId = null;
4585
+ /**
4586
+ * #1901: the id a unit of async work with no call around it logs under — a consumer a
4587
+ * seed or an import delivered — set for its handler's duration and cleared after. Read
4588
+ * by `ctx.log` only, never the spine: an event a consumer emits outside a call still
4589
+ * records no invocation (#1525), while its log lines join the consumer's own line.
4590
+ */
4591
+ unitInvocationId = null;
3877
4592
  async dispatch(tenantId, scopeId,
3878
4593
  /**
3879
4594
  * #1525: the call this drain is running in, or null. Every round of the loop
@@ -3881,13 +4596,23 @@ export function defineScopeDO(modules, bareOps) {
3881
4596
  * whole cascade is one call's work.
3882
4597
  */
3883
4598
  invocationId) {
4599
+ // #1901: one line per delivery, capped per drain.
4600
+ const lines = asyncLinePass();
4601
+ try {
4602
+ await this.dispatchRounds(tenantId, scopeId, invocationId, lines);
4603
+ }
4604
+ finally {
4605
+ lines.end();
4606
+ }
4607
+ }
4608
+ async dispatchRounds(tenantId, scopeId, invocationId, lines) {
3884
4609
  for (let round = 0; round < 50; round++) {
3885
4610
  let deliveredAny = false;
3886
4611
  for (const mod of this.modules.values()) {
3887
4612
  for (const consumer of mod.consumers) {
3888
4613
  const rows = this.sql
3889
4614
  .exec(`SELECT * FROM _substrat_outbox o
3890
- WHERE o.type = ?
4615
+ WHERE o.type = ? AND ${emittedHere('o.')}
3891
4616
  AND NOT EXISTS (
3892
4617
  SELECT 1 FROM _substrat_deliveries d
3893
4618
  WHERE d.event_id = o.id AND d.consumer_module = ?
@@ -3895,6 +4620,20 @@ export function defineScopeDO(modules, bareOps) {
3895
4620
  ORDER BY o.id`, consumer.eventType, mod.id)
3896
4621
  .toArray();
3897
4622
  for (const row of rows) {
4623
+ // #1901: this delivery's line — the call's id when it runs in one, else its own.
4624
+ const unit = {
4625
+ kind: 'consumer',
4626
+ tenantId,
4627
+ scopeId,
4628
+ invocationId: asyncInvocationId(invocationId),
4629
+ operation: mod.id,
4630
+ eventType: consumer.eventType,
4631
+ eventId: row.id,
4632
+ // A module consumer is tried once: a throw dead-letters it (v0).
4633
+ attempt: 1,
4634
+ startedAt: Date.now(),
4635
+ versionId: this.env.SUBSTRAT_VERSION_ID ?? null,
4636
+ };
3898
4637
  let event;
3899
4638
  try {
3900
4639
  event = domainEventOf(row);
@@ -3908,14 +4647,18 @@ export function defineScopeDO(modules, bareOps) {
3908
4647
  this.sql.exec(`INSERT INTO _substrat_deliveries
3909
4648
  (event_id, consumer_module, delivered_at, error, invocation_id)
3910
4649
  VALUES (?, ?, ?, ?, ?)`, row.id, mod.id, new Date().toISOString(), String(err), invocationId);
4650
+ // Never reached the handler, so nothing threw in it: a warning, not an error.
4651
+ lines.write({ ...unit, outcome: 'dead-lettered' });
3911
4652
  continue;
3912
4653
  }
3913
4654
  // #1237: anything this consumer emits was emitted BECAUSE of this event
3914
4655
  // — the step a backwards walk used to stop dead at, since a consumer
3915
4656
  // emit records no operation either.
3916
4657
  this.causedBy = event.id;
4658
+ // #1901: the handler's `ctx.log` lines join this delivery's line.
4659
+ this.unitInvocationId = unit.invocationId;
3917
4660
  try {
3918
- await this.ctx.storage.transaction(async () => {
4661
+ await this.revision.transaction(async () => {
3919
4662
  const ctx = this.operationContext(this.systemPrincipal, tenantId, scopeId, {
3920
4663
  system: mod.id,
3921
4664
  });
@@ -3925,6 +4668,7 @@ export function defineScopeDO(modules, bareOps) {
3925
4668
  VALUES (?, ?, ?, ?)`, event.id, mod.id, new Date().toISOString(), invocationId);
3926
4669
  });
3927
4670
  deliveredAny = true;
4671
+ lines.write({ ...unit, outcome: 'delivered' });
3928
4672
  }
3929
4673
  catch (err) {
3930
4674
  // Dead-letter (v0): journal the failure so one poison event
@@ -3932,8 +4676,10 @@ export function defineScopeDO(modules, bareOps) {
3932
4676
  this.sql.exec(`INSERT INTO _substrat_deliveries
3933
4677
  (event_id, consumer_module, delivered_at, error, invocation_id)
3934
4678
  VALUES (?, ?, ?, ?, ?)`, event.id, mod.id, new Date().toISOString(), String(err), invocationId);
4679
+ lines.write({ ...unit, outcome: 'dead-lettered', error: err });
3935
4680
  }
3936
4681
  finally {
4682
+ this.unitInvocationId = null;
3937
4683
  // Cleared on BOTH paths. Left set, the id leaks onto every later emit
3938
4684
  // this DO makes — an operation's own event stamped as caused by
3939
4685
  // whatever was delivered last, which is worse than recording nothing
@@ -3953,9 +4699,9 @@ export function defineScopeDO(modules, bareOps) {
3953
4699
  * survives — the whole point, since the denial is the write the operation could not make.
3954
4700
  */
3955
4701
  /** K-42: the session a refused call ran under travels with the denial row. */
3956
- /** #1745: a refused transition the operation failed with, written after its rollback. */
4702
+ /** #1745: a refused transition or refusing guard the operation failed with, written after its rollback. */
3957
4703
  recordRefusal(subject, tenantId, scopeId, operation, err, invocationId, impersonation) {
3958
- const refused = refusedTransitionOf(err);
4704
+ const refused = refusalOf(err);
3959
4705
  if (!refused)
3960
4706
  return;
3961
4707
  const q = refusalInsert({
@@ -4006,7 +4752,12 @@ export function defineScopeDO(modules, bareOps) {
4006
4752
  invocationId, new Date().toISOString());
4007
4753
  }
4008
4754
  // -- operation context (port of operationContext) -------------------------
4009
- operationContext(principal, tenantId, scopeId, systemActor, connectionId, systemModuleId,
4755
+ operationContext(principal, tenantId, scopeId, systemActor, connectionId,
4756
+ /**
4757
+ * #1834: the system subject, only as the door check hands it out (`assertSystemDoor`) — so
4758
+ * no path can act as `system:<moduleId>` without passing that check.
4759
+ */
4760
+ systemDoor,
4010
4761
  /** #458: per-invoke tally of `ctx.requestPlatform` calls; absent for consumer dispatch. */
4011
4762
  signals,
4012
4763
  /**
@@ -4071,8 +4822,8 @@ export function defineScopeDO(modules, bareOps) {
4071
4822
  ? peerSubject
4072
4823
  : capabilityId
4073
4824
  ? { kind: 'capability', id: capabilityId }
4074
- : systemModuleId
4075
- ? { kind: 'system', id: systemModuleId }
4825
+ : systemDoor
4826
+ ? { kind: 'system', id: systemDoor.moduleId }
4076
4827
  : connectionId
4077
4828
  ? { kind: 'connection', id: connectionId }
4078
4829
  : { kind: 'principal', id: principal };
@@ -4101,7 +4852,7 @@ export function defineScopeDO(modules, bareOps) {
4101
4852
  * owns that stack itself. It is exactly this asymmetry that makes `RunSub`
4102
4853
  * closure-shaped rather than an enter/rollback/release triple.
4103
4854
  */
4104
- const runSub = (_depth, fn) => this.ctx.storage.transaction(fn);
4855
+ const runSub = (_depth, fn) => this.revision.transaction(fn);
4105
4856
  // Lifted so `grant` reuses the SAME check the operation itself passes —
4106
4857
  // a delegation check that could differ from the operation's would be a
4107
4858
  // second opinion about what the caller holds.
@@ -4198,7 +4949,7 @@ export function defineScopeDO(modules, bareOps) {
4198
4949
  tenantId,
4199
4950
  scopeId,
4200
4951
  operation: operation ?? null,
4201
- invocationId: () => this.invocationId,
4952
+ invocationId: () => this.invocationId ?? this.unitInvocationId,
4202
4953
  principalKind: systemActor ? 'system' : subject.kind,
4203
4954
  // The string-safe redaction: `redactSecrets` parses its serialization back, and a
4204
4955
  // log's text is not JSON.
@@ -4279,7 +5030,7 @@ export function defineScopeDO(modules, bareOps) {
4279
5030
  });
4280
5031
  const rows = sql.exec(q.sql, ...q.params).toArray();
4281
5032
  const last = rows.length >= limit ? rows[rows.length - 1] : undefined;
4282
- const nextCursor = last === undefined ? null : cursorOf(last, q.sortColumn, plan.idColumn);
5033
+ const nextCursor = last === undefined ? null : cursorOf(last, q.sortColumn, plan.idColumn, q.order);
4283
5034
  const page = { entries: rows, nextCursor };
4284
5035
  if (!params.total)
4285
5036
  return page;
@@ -4512,18 +5263,23 @@ export function defineScopeDO(modules, bareOps) {
4512
5263
  // The whole body is synchronous storage work, so it is ONE transaction (#1738): the queue
4513
5264
  // serializes but does not roll back, and a role insert that throws part-way must not leave
4514
5265
  // the receipt (or a half-replaced role set) behind for `servesTenant` to trust.
4515
- return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => {
5266
+ return this.queue.enqueue(() => this.revision.transactionSync(() => {
4516
5267
  // #1738: the receipt `servesTenant` reads. First writer wins: absent or equal is written,
4517
5268
  // a receipt for ANOTHER tenant refuses the whole projection (K-3), before a single row
4518
5269
  // moves, so a misdirected projection can never re-point a scope or leave its roles behind.
4519
- // A restore does not trip this: `importDump` drops the dump's receipt, so the repair
4520
- // projection that follows finds none and writes the destination's own.
4521
- const held = this.sql.exec(`SELECT value FROM _substrat_meta WHERE key = 'provisioned_for'`).toArray()[0];
4522
- if (held && held.value !== tenantId) {
4523
- throw substratError('conflict', `applyProjection refused: this scope was provisioned for tenant ${held.value}, and a projection for tenant ${tenantId} would re-point it`);
5270
+ // A restore does not trip this: `importDump` keeps the destination's own receipt and
5271
+ // never the dump's (#2016), so the repair projection that follows finds its own tenant.
5272
+ // #2016: a scope with no receipt yet takes its first one only where its role rows agree
5273
+ // (`tenantVerdict`, the same reading the lifecycle back-fill uses), so a projection for
5274
+ // another tenant cannot pin a legacy scope to it.
5275
+ const { verdict, held } = this.tenantVerdict(tenantId);
5276
+ if (verdict === 'foreign') {
5277
+ throw substratError('conflict', held !== null
5278
+ ? `applyProjection refused: this scope was provisioned for tenant ${held}, and a projection for tenant ${tenantId} would re-point it`
5279
+ : `applyProjection ${foreignTenant(held, tenantId)}`);
4524
5280
  }
4525
5281
  // Written before any guard below can return early, so every projection leaves it.
4526
- this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES ('provisioned_for', ?)`, tenantId);
5282
+ this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES (?, ?)`, PROVISIONED_FOR_KEY, tenantId);
4527
5283
  this.sql.exec(`DELETE FROM _substrat_roles WHERE tenant_id = ?`, tenantId);
4528
5284
  for (const r of roles) {
4529
5285
  this.sql.exec(`INSERT OR REPLACE INTO _substrat_roles (tenant_id, role_key, permissions, source, revoked_at)