@substrat-run/adapter-cloudflare 0.136.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.
@@ -32,6 +32,8 @@ export interface ScopeDoEnv {
32
32
  */
33
33
  SUBSTRAT_VERSION_ID?: string;
34
34
  }
35
+ /** #2016: how a tenant reads against a scope's record (`ScopeDO.tenantVerdict`). */
36
+ export type TenantVerdict = 'recorded' | 'inferred' | 'foreign' | 'unknown';
35
37
  /**
36
38
  * What a capability attachment verb (#1686) answers: its value, or its failure as DATA —
37
39
  * `invoke`'s envelope discipline, since a throw across the RPC keeps only its message.
@@ -1 +1 @@
1
- {"version":3,"file":"scope-do.d.ts","sourceRoot":"","sources":["../src/scope-do.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAcL,KAAK,WAAW,EAyCjB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAuCL,KAAK,SAAS,EAsBd,KAAK,kBAAkB,EAEvB,KAAK,gBAAgB,EA+EtB,MAAM,sBAAsB,CAAC;AAgG9B;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,UAAU;IACzB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,sBAAsB,CAAC;IACvC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAokBD;;;GAGG;AACH,MAAM,MAAM,yBAAyB,CAAC,CAAC,IAAI;IAAE,KAAK,EAAE,CAAC,CAAC;IAAC,OAAO,CAAC,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,WAAW,CAAA;CAAE,CAAC;AAgDxG;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,UAAU,GAAG,SAAS,CAOxD;AAED,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAwDxD;AAED,wBAAgB,aAAa,CAC3B,OAAO,EAAE,kBAAkB,EAAE,EAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC,GACxD,KAAK,GAAG,EAAE,kBAAkB,EAAE,GAAG,EAAE,UAAU,KAAK,aAAa,CAwoLjE"}
1
+ {"version":3,"file":"scope-do.d.ts","sourceRoot":"","sources":["../src/scope-do.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAcL,KAAK,WAAW,EAyCjB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAuCL,KAAK,SAAS,EAsBd,KAAK,kBAAkB,EAEvB,KAAK,gBAAgB,EAsFtB,MAAM,sBAAsB,CAAC;AAgG9B;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,UAAU;IACzB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,sBAAsB,CAAC;IACvC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAwED,oFAAoF;AACpF,MAAM,MAAM,aAAa,GAAG,UAAU,GAAG,UAAU,GAAG,SAAS,GAAG,SAAS,CAAC;AAqhB5E;;;GAGG;AACH,MAAM,MAAM,yBAAyB,CAAC,CAAC,IAAI;IAAE,KAAK,EAAE,CAAC,CAAC;IAAC,OAAO,CAAC,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,WAAW,CAAA;CAAE,CAAC;AAgDxG;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,UAAU,GAAG,SAAS,CAOxD;AAED,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAwDxD;AAkBD,wBAAgB,aAAa,CAC3B,OAAO,EAAE,kBAAkB,EAAE,EAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC,GACxD,KAAK,GAAG,EAAE,kBAAkB,EAAE,GAAG,EAAE,UAAU,KAAK,aAAa,CAs2LjE"}
package/dist/scope-do.js CHANGED
@@ -1,7 +1,8 @@
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
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';
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';
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';
@@ -15,6 +16,21 @@ import { CARRIED_AWAY_KEY, COPY_MARK_CLEARED_KEY, KEPT_COPY_REFUSAL, KEPT_DIVERG
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,7 +338,8 @@ 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.
@@ -644,6 +661,15 @@ export function splitSqlStatements(sql) {
644
661
  out.push(cur.trim());
645
662
  return out;
646
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');
647
673
  export function defineScopeDO(modules, bareOps) {
648
674
  return class ScopeDO extends DurableObject {
649
675
  sql;
@@ -1106,25 +1132,76 @@ export function defineScopeDO(modules, bareOps) {
1106
1132
  * pair check), and a stray role row can no longer make a scope serve a tenant it was never
1107
1133
  * provisioned for.
1108
1134
  *
1109
- * A scope with NO receipt was provisioned before it existed, or has just been restored (a
1110
- * dump's receipt is dropped on import: it describes the scope the dump came from). Only
1111
- * then is the old inference used: `provisionScopeLocal` projected role definitions under
1112
- * the tenant, so a `_substrat_roles` row for it says the same thing. The next projection
1113
- * (reconcile, or the restore's repair) writes the receipt, after which the inference is
1114
- * 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.
1115
1141
  *
1116
1142
  * Read without migrating, so asking about a foreign scope leaves its DO as empty as it found it.
1117
1143
  */
1118
1144
  async servesTenant(tenantId) {
1119
- const has = (table) => this.sql.exec(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`, table).toArray().length > 0;
1120
- if (has('_substrat_meta')) {
1121
- const receipt = this.sql.exec(`SELECT value FROM _substrat_meta WHERE key = 'provisioned_for'`).toArray()[0];
1122
- if (receipt)
1123
- 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 };
1124
1181
  }
1125
- if (!has('_substrat_roles'))
1126
- return false;
1127
- 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;
1128
1205
  }
1129
1206
  /** #1705 PR 2: the outbox's insertion mark (`OUTBOX_MARK_SQL`). */
1130
1207
  outboxMark() {
@@ -1625,21 +1702,33 @@ export function defineScopeDO(modules, bareOps) {
1625
1702
  * `vertical.honoured`: an old DO that ignored this argument would run the call as that
1626
1703
  * placeholder, and the coordinator refuses a success without the acknowledgement.
1627
1704
  */
1628
- 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) {
1629
1713
  if (!failureEnvelope) {
1630
1714
  // Legacy path, byte-for-byte what it was: rewrapped so a non-plain error (a
1631
1715
  // ZodError, whose `message` is a getter) still arrives with its message.
1632
1716
  try {
1633
- 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);
1634
1718
  }
1635
1719
  catch (err) {
1720
+ if (err instanceof SystemDoorMovedError)
1721
+ return { result: undefined, platformRequests: 0, ...SYSTEM_DOOR_MOVED };
1636
1722
  throw toRpcError(err);
1637
1723
  }
1638
1724
  }
1639
1725
  try {
1640
- 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);
1641
1727
  }
1642
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 };
1643
1732
  // The ONE place the error keeps its structure: flattened here, rebuilt by the
1644
1733
  // coordinator. `toRpcError` is not applied — that exists to make a throw
1645
1734
  // survivable, and this is not a throw.
@@ -1653,7 +1742,9 @@ export function defineScopeDO(modules, bareOps) {
1653
1742
  /** #1672: a capability session's hash. See `invoke` above. */
1654
1743
  capabilitySession,
1655
1744
  /** #1706: the calling vertical, as the platform named it. See `invoke` above. */
1656
- verticalCaller) {
1745
+ verticalCaller,
1746
+ /** #1834: the instance the system door's gate read. See `invoke` above. */
1747
+ systemDoorInstance) {
1657
1748
  await this.ensureMigrations();
1658
1749
  const handler = this.operations.get(operation);
1659
1750
  // `not_found`, not a bare throw (#113): every vertical hand-matched this message
@@ -1736,6 +1827,9 @@ export function defineScopeDO(modules, bareOps) {
1736
1827
  // one call. Same placement as the SQLite adapter's actor task, for this reason.
1737
1828
  this.invocationId = invokeOptions?.invocationId ?? null;
1738
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);
1739
1833
  // #1672: the capability session, resolved on EVERY call and here — inside the queued
1740
1834
  // body, the one region where this call holds the DO to itself — so nothing can
1741
1835
  // revoke between this read and the transaction. Refuses a stale session, a revoked
@@ -1810,7 +1904,7 @@ export function defineScopeDO(modules, bareOps) {
1810
1904
  // emitted events back as one — verified across `await` in workerd.
1811
1905
  try {
1812
1906
  await this.revision.transaction(async () => {
1813
- const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemModuleId, signals, impersonation, operation, capabilityId, minted, undefined, peerSubject);
1907
+ const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, systemDoor, signals, impersonation, operation, capabilityId, minted, undefined, peerSubject);
1814
1908
  // #116: a retry is answered from the recording, and nothing else runs
1815
1909
  // — not the guards, not the handler, not the permission check inside
1816
1910
  // it. Keyed by SUBJECT, so a caller only ever reaches its own
@@ -1878,6 +1972,7 @@ export function defineScopeDO(modules, bareOps) {
1878
1972
  impersonation: { honoured: true },
1879
1973
  ...(capabilitySession !== undefined ? { capability: { honoured: true } } : {}),
1880
1974
  ...(verticalCaller !== undefined ? { vertical: { honoured: true } } : {}),
1975
+ ...(systemDoorInstance !== undefined ? { systemDoor: { honoured: true } } : {}),
1881
1976
  ...(idempotencyKey !== undefined
1882
1977
  ? { idempotency: { keyHonoured: true, replayed } }
1883
1978
  : {}),
@@ -1942,6 +2037,8 @@ export function defineScopeDO(modules, bareOps) {
1942
2037
  ...(capabilitySession !== undefined ? { capability: { honoured: true } } : {}),
1943
2038
  // #1706: the same, for the peer door.
1944
2039
  ...(verticalCaller !== undefined ? { vertical: { honoured: true } } : {}),
2040
+ // #1834: the same, for the system door's instance pin.
2041
+ ...(systemDoorInstance !== undefined ? { systemDoor: { honoured: true } } : {}),
1945
2042
  // The acknowledgement the coordinator's skew check reads (#116), on the
1946
2043
  // same reasoning as `ifMatchChecked` below and with a sharper failure: a
1947
2044
  // DO too old to know about keys would EXECUTE THE OPERATION AGAIN and
@@ -2383,8 +2480,19 @@ export function defineScopeDO(modules, bareOps) {
2383
2480
  return record;
2384
2481
  }
2385
2482
  /** Authorize a job's read as its registered module, never as a person. */
2386
- 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) {
2387
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
+ }
2388
2496
  if (!this.modules.has(moduleId)) {
2389
2497
  throw toRpcError(substratError('not_found', `module not registered in this scope: ${moduleId}`));
2390
2498
  }
@@ -2393,7 +2501,7 @@ export function defineScopeDO(modules, bareOps) {
2393
2501
  return null;
2394
2502
  const gate = this.attachmentGate(record.entity.entityType);
2395
2503
  try {
2396
- 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');
2397
2505
  assertAllowed(await ctx.check(gate.read, record.entity));
2398
2506
  }
2399
2507
  catch (err) {
@@ -2585,6 +2693,41 @@ export function defineScopeDO(modules, bareOps) {
2585
2693
  async systemScheduleState(moduleId) {
2586
2694
  return systemScheduleState(this.switchSql(), moduleId, new Date().toISOString());
2587
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
+ }
2588
2731
  /**
2589
2732
  * The pre-#1666 read, kept for ONE reason: a coordinator a deploy behind this DO still
2590
2733
  * calls it. It answers the new question, not the old one — the old predicate counted any
@@ -2614,8 +2757,8 @@ export function defineScopeDO(modules, bareOps) {
2614
2757
  * `switchSystemSchedules`, serialized on the queue with every other tuple write and run
2615
2758
  * as one `transactionSync`, so its reads and writes are one unit.
2616
2759
  */
2617
- async switchSystemSchedules(moduleId, scopeId, to, at) {
2618
- const outcome = await this.queue.enqueue(() => this.revision.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 })));
2619
2762
  // #1819: the instance that applied it, which the rewind hold's release rule reads.
2620
2763
  return { ...outcome, instance: this.instanceId };
2621
2764
  }
@@ -3093,6 +3236,18 @@ export function defineScopeDO(modules, bareOps) {
3093
3236
  ORDER BY id LIMIT ?`, now, afterId ?? null, afterId ?? null, limit)
3094
3237
  .toArray();
3095
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
+ }
3096
3251
  /** The operator read, newest first. */
3097
3252
  async jobRunList(filter) {
3098
3253
  const where = [];
@@ -3174,7 +3329,15 @@ export function defineScopeDO(modules, bareOps) {
3174
3329
  throw new Error(`unknown guard predicate: '${guard.predicate}' — declared by ${guard.declaredBy} ` +
3175
3330
  `before '${operation}'; no registered module contributes it (operation blocked)`);
3176
3331
  }
3177
- 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
+ }
3178
3341
  }
3179
3342
  }
3180
3343
  // -- migrations (port of applyPendingMigrations) --------------------------
@@ -3324,10 +3487,28 @@ export function defineScopeDO(modules, bareOps) {
3324
3487
  * older than the one held. Bookkeeping, like the copy marker: it changes what runs here, not
3325
3488
  * the scope's data, so it does not advance the write revision a carry fences on.
3326
3489
  */
3327
- setLifecycle(next) {
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());
3328
3503
  let out;
3329
3504
  this.revision.transactionSync(() => {
3330
- out = this.revision.bookkeeping(() => writeLifecycle(this.switchSql(), next));
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 };
3331
3512
  });
3332
3513
  return out;
3333
3514
  }
@@ -3826,6 +4007,14 @@ export function defineScopeDO(modules, bareOps) {
3826
4007
  // both adapters by the query-plan test rather than by that gate.
3827
4008
  this.sql.exec('CREATE INDEX IF NOT EXISTS _substrat_outbox_invocation ON _substrat_outbox (invocation_id, id)');
3828
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);
3829
4018
  }
3830
4019
  /**
3831
4020
  * #1288: `_substrat_schedule_state`, rebuilt with `kind` in its key on a scope DO
@@ -3837,23 +4026,31 @@ export function defineScopeDO(modules, bareOps) {
3837
4026
  * the stored DDL is the one probe both adapters can make.
3838
4027
  */
3839
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) {
3840
4037
  const row = this.sql
3841
- .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)
3842
4039
  .toArray()[0];
3843
- if (!row || scheduleStateHasKind(row.sql))
4040
+ if (!row || isCurrent(row.sql))
3844
4041
  return;
3845
- // 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-
3846
4043
  // drop-rename has two intermediate states and both are unrecoverable on the next
3847
4044
  // wake. `transactionSync`, not the async one every operation uses — the DO
3848
4045
  // runtime forbids a manual BEGIN through `sql.exec`, and this body is wholly
3849
4046
  // synchronous, which is the one case the sync API is for (it commits at the
3850
4047
  // first await, and there is none). It also has to be sync because the caller is.
3851
4048
  this.revision.transactionSync(() => {
3852
- for (const stmt of splitSqlStatements(SCHEDULE_STATE_REBUILD))
4049
+ for (const stmt of splitSqlStatements(script))
3853
4050
  this.sql.exec(stmt);
3854
4051
  });
3855
4052
  }
3856
- async importDump(tables, destScopeId, { switchOff, sourceScopeId, exact, loadStamp, expect, resolveKept, markCopy, } = {}) {
4053
+ async importDump(tables, destScopeId, { switchOff, sourceScopeId, exact, loadStamp, expect, resolveKept, markCopy, provisionedFor, } = {}) {
3857
4054
  // The WHOLE drop-then-replay runs under deferred foreign keys, in one transaction.
3858
4055
  //
3859
4056
  // Two distinct FK hazards, and both need the deferral — the second is why it cannot
@@ -3892,6 +4089,15 @@ export function defineScopeDO(modules, bareOps) {
3892
4089
  const before = this.loadMarker();
3893
4090
  // #1713: the lifecycle the platform delivered here, read before the drops take it.
3894
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;
4100
+ }
3895
4101
  // #1722 (Codex #2008 r7): a kept copy holds writes nothing else has. No load replaces it
3896
4102
  // except its own resolution, and that only at the revision the operator acted on.
3897
4103
  const kept = this.metaValue(KEPT_DIVERGENT_KEY);
@@ -3962,9 +4168,15 @@ export function defineScopeDO(modules, bareOps) {
3962
4168
  this.sql.exec(insert, ...row);
3963
4169
  }
3964
4170
  // #1738: a dump's `provisioned_for` names the scope (and tenant) it was captured from,
3965
- // not this one. Dropped, so a restore never carries a receipt over; the repair
3966
- // projection that follows writes this scope's own.
3967
- this.sql.exec(`DELETE FROM _substrat_meta WHERE key = 'provisioned_for'`);
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
+ }
3968
4180
  // #1722: whatever stamp an export read here no longer describes this store, so every load
3969
4181
  // replaces it: with the carry's own, or with none. And the write revision moves on from
3970
4182
  // where it stood before the drops, so it never goes back.
@@ -4077,6 +4289,8 @@ export function defineScopeDO(modules, bareOps) {
4077
4289
  const code = errorCodeOf(e);
4078
4290
  if (code === 'precondition_failed')
4079
4291
  return { refused: 'changed' };
4292
+ if (e instanceof Error && tenantRefusals.has(e))
4293
+ return { refused: 'tenant', message: e.message };
4080
4294
  if (code === 'conflict')
4081
4295
  return { refused: 'kept' };
4082
4296
  throw toRpcError(e);
@@ -4485,9 +4699,9 @@ export function defineScopeDO(modules, bareOps) {
4485
4699
  * survives — the whole point, since the denial is the write the operation could not make.
4486
4700
  */
4487
4701
  /** K-42: the session a refused call ran under travels with the denial row. */
4488
- /** #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. */
4489
4703
  recordRefusal(subject, tenantId, scopeId, operation, err, invocationId, impersonation) {
4490
- const refused = refusedTransitionOf(err);
4704
+ const refused = refusalOf(err);
4491
4705
  if (!refused)
4492
4706
  return;
4493
4707
  const q = refusalInsert({
@@ -4538,7 +4752,12 @@ export function defineScopeDO(modules, bareOps) {
4538
4752
  invocationId, new Date().toISOString());
4539
4753
  }
4540
4754
  // -- operation context (port of operationContext) -------------------------
4541
- 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,
4542
4761
  /** #458: per-invoke tally of `ctx.requestPlatform` calls; absent for consumer dispatch. */
4543
4762
  signals,
4544
4763
  /**
@@ -4603,8 +4822,8 @@ export function defineScopeDO(modules, bareOps) {
4603
4822
  ? peerSubject
4604
4823
  : capabilityId
4605
4824
  ? { kind: 'capability', id: capabilityId }
4606
- : systemModuleId
4607
- ? { kind: 'system', id: systemModuleId }
4825
+ : systemDoor
4826
+ ? { kind: 'system', id: systemDoor.moduleId }
4608
4827
  : connectionId
4609
4828
  ? { kind: 'connection', id: connectionId }
4610
4829
  : { kind: 'principal', id: principal };
@@ -5048,14 +5267,19 @@ export function defineScopeDO(modules, bareOps) {
5048
5267
  // #1738: the receipt `servesTenant` reads. First writer wins: absent or equal is written,
5049
5268
  // a receipt for ANOTHER tenant refuses the whole projection (K-3), before a single row
5050
5269
  // moves, so a misdirected projection can never re-point a scope or leave its roles behind.
5051
- // A restore does not trip this: `importDump` drops the dump's receipt, so the repair
5052
- // projection that follows finds none and writes the destination's own.
5053
- const held = this.sql.exec(`SELECT value FROM _substrat_meta WHERE key = 'provisioned_for'`).toArray()[0];
5054
- if (held && held.value !== tenantId) {
5055
- 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)}`);
5056
5280
  }
5057
5281
  // Written before any guard below can return early, so every projection leaves it.
5058
- 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);
5059
5283
  this.sql.exec(`DELETE FROM _substrat_roles WHERE tenant_id = ?`, tenantId);
5060
5284
  for (const r of roles) {
5061
5285
  this.sql.exec(`INSERT OR REPLACE INTO _substrat_roles (tenant_id, role_key, permissions, source, revoked_at)