@substrat-run/adapter-cloudflare 0.137.0 → 0.138.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/scope-do.js CHANGED
@@ -2,12 +2,13 @@ import { REWIND_REFUSED } from './rewind-refusal.js';
2
2
  import { SYSTEM_DOOR_MOVED } from './system-door.js';
3
3
  import { DurableObject } from 'cloudflare:workers';
4
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';
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';
5
+ import { ulid, DO_SQL_LIMITS, unknownRoleError, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, redactSubjectScopeText, assertRowLimit, assertRowOffset, JOB_RUN_PATCH_SQL, JOB_RUN_CLAIM_SQL, JOB_RUN_RENEW_SQL, JOB_RUN_BEGIN_SQL, JOB_RUN_MISS_SQL, JOB_RUN_MISS_SETTLE_SQL, admissionMissOutcome, JOB_LEASE_EXPIRED_NOTE, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, effectiveRoleGrantQuery, switchRecordedOff, switchSystemSchedules, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, denialListQuery, refusalListQuery, mapRefusalRow, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexPlans, NotListable, listIndexPlans, moduleMigrations, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, attachmentRecordOfRow, enqueueAttachmentText, reconcileAttachmentText, recordAttachmentText, searchAttachments, IDEMPOTENCY_DDL, REFUSALS_TABLE_DDL, REFUSALS_INDEX, refusalInsert, refusalOf, markGuardRefusal, REFUSALS_REBUILD, refusalsAdmitGuards, assertIdempotencyKey, assertPermissionKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, emptyImportResult, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, exportedSinceQuery, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, JOB_RUN_DUE_AT, jobRunListLimit, SYSTEM_DOOR_WAIT, ancestorsWithin, } from '@substrat-run/kernel';
6
+ import { isUpgradeRequest, readSubscription, LIVE_FANOUT_LIMIT, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, LIVE_WITHIN_HEADER, decodeLiveWithin, } from './live-reads.js';
7
+ import { replyOf } from './do-reply.js';
7
8
  import { OperationQueue } from './serialization.js';
8
9
  import { doScopedSql, doBuiltColumnsOf, doRedactionSql, doSpineSql } from './sql.js';
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';
10
- import { createDoTupleChecker, createLocalControlPlaneReader } from './checker.js';
10
+ import { actorOf, admitPeer, collectPeers, switchPeer, peerSubjectRef, subjectGrantState, assertNoSecret, CAPABILITY_DDL, COPY_ORIGIN_DDL, SWITCH_FENCES_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, createCapabilityVerbs, createEntityEdgeVerbs, exchangeCapability, guardSecrets, mintBecomeCapability, redactSecrets, redactSecretText, moduleLog, asyncInvocationId, asyncLinePass, resolveCapabilitySession, revokeCapabilityAsPlatform, readCapabilityPage, domainEventOf, facetEvents, readDeadLetters, readLifecycleFlow, readOperationSeries, readHistory, readInvocation, readUndrainedOutbox, walkEventCause, walkEventEffects, } from '@substrat-run/kernel';
11
+ import { createDoTupleChecker, createLocalControlPlaneReader, scopeTupleReader } from './checker.js';
11
12
  import { CARRIED_AWAY_KEY, COPY_MARK_CLEARED_KEY, KEPT_COPY_REFUSAL, KEPT_DIVERGENT_KEY, LOAD_STAMP_KEY, STORE_LOCAL_META_KEYS, WRITE_REVISION_KEY, carriedAwayDump, isCopyMarkInsert, isWriteStatement, assertNoSpineReference, assertSpineTablesBuilt, capabilitiesForLoad, clearCopyMarker, dumpRowsInsert, isSpineTable, markCopyOrigin, repointScopeGrants, settleCopiedWork, emittedHere, IS_COPY_SQL, isCopyLoad, isLifecycleWrite, readLifecycle, settleLifecycleAfterLoad, writeLifecycle, spineColumnAdditions } from '@substrat-run/kernel';
12
13
  /**
13
14
  * The key marking a scope DO whose storage was destroyed (`destroyStorage`).
@@ -190,6 +191,7 @@ const KERNEL_DDL = `
190
191
  taken_at TEXT NOT NULL,
191
192
  pending TEXT NOT NULL
192
193
  );
194
+ ${SWITCH_FENCES_DDL}
193
195
  CREATE TABLE IF NOT EXISTS _substrat_tuples (
194
196
  subject TEXT NOT NULL,
195
197
  relation TEXT NOT NULL,
@@ -1067,7 +1069,16 @@ export function defineScopeDO(modules, bareOps) {
1067
1069
  * On the queue, so no operation is mid-transaction while the outbox is read. An event
1068
1070
  * must not reach another vertical before the transaction that wrote it has committed.
1069
1071
  */
1070
- async exportedEventsRead(input, tenantId, scopeId) {
1072
+ async exportedEventsRead(input, tenantId, scopeId,
1073
+ /**
1074
+ * #2029: the consumer is a peer, so the read is gated by its door. `instance` pins it to the
1075
+ * gate's read, as every door call is; `held` says the rewind hold keeps the consumer off, and
1076
+ * then it holds nothing here: every key is missing, the answer a switched-off consumer gets.
1077
+ */
1078
+ door) {
1079
+ const held = door !== undefined && 'held' in door;
1080
+ if (!held && this.peerDoorMoved(input.consumer, door?.instance))
1081
+ return SYSTEM_DOOR_MOVED;
1071
1082
  await this.ensureMigrations();
1072
1083
  const plan = exportReadPlan(this.crossVertical.exports(), input.wants);
1073
1084
  const quiet = {
@@ -1080,9 +1091,11 @@ export function defineScopeDO(modules, bareOps) {
1080
1091
  };
1081
1092
  if (plan.types.length === 0)
1082
1093
  return quiet;
1083
- const missing = (await this.peerCovers(tenantId, scopeId, input.consumer, plan.keys))
1084
- .filter((c) => !c.held)
1085
- .map((c) => c.permission);
1094
+ const missing = held
1095
+ ? plan.keys
1096
+ : (await this.peerCoverage(tenantId, scopeId, input.consumer, plan.keys))
1097
+ .filter((c) => !c.held)
1098
+ .map((c) => c.permission);
1086
1099
  if (missing.length > 0)
1087
1100
  return { ...quiet, paused: { missing } };
1088
1101
  return await this.queue.enqueue(() => {
@@ -1221,20 +1234,16 @@ export function defineScopeDO(modules, bareOps) {
1221
1234
  * The coordinator parses the batch before it gets here and runs the executors after.
1222
1235
  * Executors live on the coordinator here, as they do after an invoke.
1223
1236
  */
1224
- async importApply(batch, tenantId, scopeId) {
1225
- await this.ensureMigrations();
1237
+ async importApply(batch, tenantId, scopeId,
1238
+ /** #2029: the instance the peer door's gate read for the producer; on another, nothing applies. */
1239
+ doorInstance) {
1226
1240
  const source = batch.source;
1241
+ if (this.peerDoorMoved(source.vertical, doorInstance))
1242
+ return SYSTEM_DOOR_MOVED;
1243
+ await this.ensureMigrations();
1227
1244
  return await this.queue.enqueue(async () => {
1228
1245
  const liveSince = this.liveHighWaterMark();
1229
- const result = {
1230
- delivered: 0,
1231
- deadLettered: 0,
1232
- duplicates: 0,
1233
- withheld: 0,
1234
- cursor: batch.after,
1235
- stale: false,
1236
- paused: null,
1237
- };
1246
+ const result = emptyImportResult(batch);
1238
1247
  // #1706's door, for a delivery (`operation: null`), inside the queued body: the producer
1239
1248
  // is a declared peer with its switch on, or nothing runs and the edge pauses.
1240
1249
  let peerSubject;
@@ -1460,6 +1469,10 @@ export function defineScopeDO(modules, bareOps) {
1460
1469
  lifecycleFlow(input) {
1461
1470
  return readLifecycleFlow({ sql: doScopedSql(this.sql) }, input);
1462
1471
  }
1472
+ /** #1750: business volumes per bucket, counted over this scope's outbox. */
1473
+ operationSeries(input) {
1474
+ return readOperationSeries({ sql: doScopedSql(this.sql) }, input);
1475
+ }
1463
1476
  migrationBookmarks(limit = 20) {
1464
1477
  return this.sql
1465
1478
  .exec(`SELECT bookmark, taken_at, pending FROM _substrat_migration_bookmarks
@@ -1845,6 +1858,8 @@ export function defineScopeDO(modules, bareOps) {
1845
1858
  // operation off its allowlist are refused `forbidden`; none is a K-35 denial.
1846
1859
  let peerSubject;
1847
1860
  if (verticalCaller !== undefined) {
1861
+ // #2029: pinned to the instance the peer door's gate read against the rewind hold.
1862
+ this.assertPeerDoor(verticalCaller.vertical, systemDoorInstance);
1848
1863
  peerSubject = admitPeer(this.switchSql(), this.peers, verticalCaller, operation);
1849
1864
  idempotencySubjectRef = peerSubject;
1850
1865
  }
@@ -2119,6 +2134,14 @@ export function defineScopeDO(modules, bareOps) {
2119
2134
  status: 500,
2120
2135
  });
2121
2136
  }
2137
+ // A narrowing the coordinator sent but this end cannot read is refused, never
2138
+ // dropped: dropping it would open the whole scope's feed — to a vouched
2139
+ // subscriber, one the principal's check was never going to filter (#1853).
2140
+ const withinHeader = request.headers.get(LIVE_WITHIN_HEADER);
2141
+ const within = withinHeader === null ? undefined : decodeLiveWithin(withinHeader);
2142
+ if (withinHeader !== null && !within) {
2143
+ return new Response('live reads: unreadable within narrowing', { status: 500 });
2144
+ }
2122
2145
  // A subscriber arriving before the scope's migrations have run would be told
2123
2146
  // about events against a schema it cannot read back through. Same gate every
2124
2147
  // other entry point takes, for the same reason.
@@ -2143,6 +2166,7 @@ export function defineScopeDO(modules, bareOps) {
2143
2166
  tenantId: tenantIdOf.parse(tenantId),
2144
2167
  scopeId: scopeIdOf.parse(scopeId),
2145
2168
  since: new Date().toISOString(),
2169
+ ...(within ? { within } : {}),
2146
2170
  });
2147
2171
  return new Response(null, { status: 101, webSocket: client });
2148
2172
  }
@@ -2266,6 +2290,11 @@ export function defineScopeDO(modules, bareOps) {
2266
2290
  * to nobody. Knowing that a row exists and changed at 14:02 is information about
2267
2291
  * that row, so the empty payload is not what makes this safe; this is.
2268
2292
  *
2293
+ * *Narrowed, when the subscription asked to be* (#1853). A socket opened `within` an
2294
+ * entity hears only rows that reach it through live parent edges — each row's
2295
+ * ancestors are walked once per pass, however many sockets ask. A vouched root
2296
+ * replaces the check above, and its frames are `LiveNudge`s.
2297
+ *
2269
2298
  * *Never able to fail the operation.* The write has committed and the caller has
2270
2299
  * its answer. A socket that has gone away mid-fan-out, or a check that cannot be
2271
2300
  * evaluated, costs a subscriber its live update — which it survives, because the
@@ -2285,6 +2314,25 @@ export function defineScopeDO(modules, bareOps) {
2285
2314
  const announceable = rows.filter((r) => this.liveTargets.has(r.entity_type));
2286
2315
  if (announceable.length === 0)
2287
2316
  return;
2317
+ /**
2318
+ * Is a row's entity beneath a `within` root (#1853)? Each row's ancestors are walked
2319
+ * once per pass, on first need, so a check per socket is a set lookup — every widget
2320
+ * visitor's root is a different session, and a walk per (row, socket) would read the
2321
+ * same parent edges once per visitor. Post-commit state: a `ctx.relink` in the same
2322
+ * operation has already moved the edge the walk follows.
2323
+ */
2324
+ const parents = scopeTupleReader(this.sql);
2325
+ const now = new Date().toISOString();
2326
+ const ancestors = new Map();
2327
+ const reaches = async (row, root) => {
2328
+ let up = ancestors.get(row.id);
2329
+ if (!up) {
2330
+ // A walk that cannot answer is a frame not sent — the same fail-closed rule as the check.
2331
+ up = ancestorsWithin(parents, { entityType: row.entity_type, entityId: row.entity_id }, now).catch(() => new Set());
2332
+ ancestors.set(row.id, up);
2333
+ }
2334
+ return (await up).has(`${root.entityType}:${root.entityId}`);
2335
+ };
2288
2336
  for (const ws of sockets) {
2289
2337
  let subscription = null;
2290
2338
  try {
@@ -2303,8 +2351,10 @@ export function defineScopeDO(modules, bareOps) {
2303
2351
  // assumption about how the subscription was created.
2304
2352
  if (subscription.tenantId !== tenantId || subscription.scopeId !== scopeId)
2305
2353
  continue;
2354
+ const { within } = subscription;
2306
2355
  // One context per subscriber, not per event: `ctx.check` is the expensive part
2307
- // and the context is only the subject it is evaluated for.
2356
+ // and the context is only the subject it is evaluated for. Built on first use, so
2357
+ // a vouched subscriber — whose frames the walk alone decides — never builds one.
2308
2358
  //
2309
2359
  // A frame this subscriber does not pass is NOT recorded as a denial (K-35),
2310
2360
  // and that is deliberate: `recordDenial` is called on a refused REQUEST, where
@@ -2315,35 +2365,50 @@ export function defineScopeDO(modules, bareOps) {
2315
2365
  //
2316
2366
  // The operation name is carried anyway, for the events a fan-out cannot emit
2317
2367
  // but a future reader of this context might.
2318
- const ctx = this.operationContext(subscription.principal, tenantId, scopeId, undefined, undefined, undefined, undefined, undefined, 'live.subscribe');
2368
+ let ctx;
2369
+ const { principal } = subscription;
2370
+ const context = () => (ctx ??= this.operationContext(principal, tenantId, scopeId, undefined, undefined, undefined, undefined, undefined, 'live.subscribe'));
2319
2371
  for (const row of announceable) {
2320
- // Non-null: `announceable` is exactly the rows whose type is in the map.
2321
- const permission = this.liveTargets.get(row.entity_type);
2322
- let allowed = false;
2323
- try {
2324
- const decision = await ctx.check(permission, {
2372
+ // Narrowing first: it is memoised across sockets, and a row outside the root
2373
+ // is out whatever the principal holds.
2374
+ if (within && !(await reaches(row, within)))
2375
+ continue;
2376
+ let frame;
2377
+ if (within?.vouched !== undefined) {
2378
+ // The vertical vouched for the root, so the walk above was the whole filter
2379
+ // — and the subscriber holds no read on this row, so it is told only that
2380
+ // something beneath its root changed. Never which row, never how.
2381
+ frame = { kind: 'nudge', id: row.id, at: row.occurred_at };
2382
+ }
2383
+ else {
2384
+ // Non-null: `announceable` is exactly the rows whose type is in the map.
2385
+ const permission = this.liveTargets.get(row.entity_type);
2386
+ let allowed = false;
2387
+ try {
2388
+ const decision = await context().check(permission, {
2389
+ entityType: row.entity_type,
2390
+ entityId: row.entity_id,
2391
+ });
2392
+ allowed = decision.allowed;
2393
+ }
2394
+ catch {
2395
+ // A check that cannot answer is a check that refuses. The alternative —
2396
+ // treating an evaluator failure as an allow — turns an outage in the
2397
+ // permission path into a disclosure, which is the one failure mode this
2398
+ // surface must not have.
2399
+ allowed = false;
2400
+ }
2401
+ if (!allowed)
2402
+ continue;
2403
+ frame = {
2404
+ kind: 'change',
2405
+ id: row.id,
2406
+ type: row.type,
2325
2407
  entityType: row.entity_type,
2326
2408
  entityId: row.entity_id,
2327
- });
2328
- allowed = decision.allowed;
2329
- }
2330
- catch {
2331
- // A check that cannot answer is a check that refuses. The alternative —
2332
- // treating an evaluator failure as an allow — turns an outage in the
2333
- // permission path into a disclosure, which is the one failure mode this
2334
- // surface must not have.
2335
- allowed = false;
2409
+ at: row.occurred_at,
2410
+ };
2336
2411
  }
2337
- if (!allowed)
2338
- continue;
2339
- const frame = {
2340
- kind: 'change',
2341
- id: row.id,
2342
- type: row.type,
2343
- entityType: row.entity_type,
2344
- entityId: row.entity_id,
2345
- at: row.occurred_at,
2346
- };
2347
2412
  try {
2348
2413
  ws.send(JSON.stringify(frame));
2349
2414
  }
@@ -2565,13 +2630,10 @@ export function defineScopeDO(modules, bareOps) {
2565
2630
  */
2566
2631
  async attachmentSearch(term, limit, principal, tenantId, scopeId, connectionId) {
2567
2632
  await this.ensureMigrations();
2568
- try {
2633
+ return replyOf(() => {
2569
2634
  const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, undefined, undefined, undefined, 'attachments.search');
2570
- return { value: await this.searchAttachmentsAs(ctx, term, limit) };
2571
- }
2572
- catch (err) {
2573
- return { failure: toWireFailure(err) };
2574
- }
2635
+ return this.searchAttachmentsAs(ctx, term, limit);
2636
+ });
2575
2637
  }
2576
2638
  /**
2577
2639
  * #1575: the extraction job's read of one record. No gate, deliberately — extraction
@@ -2714,19 +2776,48 @@ export function defineScopeDO(modules, bareOps) {
2714
2776
  assertSystemDoor(moduleId, expected) {
2715
2777
  if (moduleId === undefined)
2716
2778
  return undefined;
2779
+ this.assertDoorPin(`module '${moduleId}'`, 'system door', expected);
2780
+ return { moduleId, [SYSTEM_DOOR_PASSED]: true };
2781
+ }
2782
+ /** #2029: the peer door's pin, for a call acting as `vertical:<slug>` — `assertDoorPin`. */
2783
+ assertPeerDoor(vertical, expected) {
2784
+ this.assertDoorPin(`vertical '${vertical}'`, 'peer door', expected);
2785
+ }
2786
+ /**
2787
+ * #2029: the peer door's pin for an RPC that answers a missed pin as `SystemDoorMoved` rather
2788
+ * than through `invoke`'s envelope: true when it missed. A call with no pin still throws.
2789
+ */
2790
+ peerDoorMoved(vertical, expected) {
2791
+ try {
2792
+ this.assertPeerDoor(vertical, expected);
2793
+ return false;
2794
+ }
2795
+ catch (err) {
2796
+ if (err instanceof SystemDoorMovedError)
2797
+ return true;
2798
+ throw err;
2799
+ }
2800
+ }
2801
+ /**
2802
+ * #1834's pin, for any door (#2029: the peer door's too). A call acting as a switched subject
2803
+ * must carry the instance the host's door gate read, and is refused on any other instance —
2804
+ * the moved one as the module-private `SystemDoorMovedError`, so the door re-gates.
2805
+ */
2806
+ assertDoorPin(what, door, expected) {
2717
2807
  // Strict, and deliberately so: no pin means no door gated this call, so it is refused rather
2718
2808
  // than run unchecked. The one innocent caller is a worker a deploy behind, still running host
2719
2809
  // code from before the pin, during a rolling deploy. Its refusal is transient: the host and this
2720
2810
  // object ship in the same bundle, so the window lasts as long as the rollout. Nothing ran, and
2721
2811
  // 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.
2812
+ // next pass, a job run retries on its next drive, and a peer caller retries.
2723
2813
  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 });
2814
+ throw substratError('forbidden', `a call acting as ${what} reached this scope without passing the ${door}`, {
2815
+ reason: SYSTEM_DOOR_WAIT,
2816
+ });
2725
2817
  }
2726
2818
  if (expected !== this.instanceId) {
2727
- throw new SystemDoorMovedError("the scope restarted after the system door's gate read it; gate it again");
2819
+ throw new SystemDoorMovedError(`the scope restarted after the ${door}'s gate read it; gate it again`);
2728
2820
  }
2729
- return { moduleId, [SYSTEM_DOOR_PASSED]: true };
2730
2821
  }
2731
2822
  /**
2732
2823
  * The pre-#1666 read, kept for ONE reason: a coordinator a deploy behind this DO still
@@ -2757,8 +2848,10 @@ export function defineScopeDO(modules, bareOps) {
2757
2848
  * `switchSystemSchedules`, serialized on the queue with every other tuple write and run
2758
2849
  * as one `transactionSync`, so its reads and writes are one unit.
2759
2850
  */
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 })));
2851
+ async switchSystemSchedules(moduleId, scopeId, to, at, tenantHeld = false,
2852
+ /** #2045: the switch call's fence — the scope refuses a move older than the one it applied. */
2853
+ fence) {
2854
+ const outcome = await this.queue.enqueue(() => this.revision.transactionSync(() => switchSystemSchedules(this.switchSql(), { moduleId, scopeId, to, at, tenantHeld, fence })));
2762
2855
  // #1819: the instance that applied it, which the rewind hold's release rule reads.
2763
2856
  return { ...outcome, instance: this.instanceId };
2764
2857
  }
@@ -2779,8 +2872,25 @@ export function defineScopeDO(modules, bareOps) {
2779
2872
  * Move one PEER's kill switch on this scope (#1706) — the kernel's `switchPeer`, which is
2780
2873
  * the schedule switch's statement with a `vertical:` subject. Queued, one transaction.
2781
2874
  */
2782
- async switchPeer(vertical, scopeId, to, at) {
2783
- return this.queue.enqueue(() => this.revision.transactionSync(() => switchPeer(this.switchSql(), { vertical, scopeId, to, at })));
2875
+ async switchPeer(vertical, scopeId, to, at,
2876
+ /** #2030: the directory holds a live tenant-level `vertical:` grant for the peer. */
2877
+ tenantHeld = false,
2878
+ /** #2045: the switch call's fence — the scope refuses a move older than the one it applied. */
2879
+ fence) {
2880
+ const outcome = await this.queue.enqueue(() => this.revision.transactionSync(() => switchPeer(this.switchSql(), { vertical, scopeId, to, at, tenantHeld, fence })));
2881
+ // #2029: the instance that applied it, which the rewind hold's release rule reads.
2882
+ return { ...outcome, instance: this.instanceId };
2883
+ }
2884
+ /**
2885
+ * #2029: the peer door's state read — where one peer stands here (`subjectGrantState` over its
2886
+ * subject, the predicate `admitPeer` refuses on), and the instance that answered it. The door
2887
+ * consults the rewind hold after this read and pins every call it then makes to this instance.
2888
+ */
2889
+ async peerDoorState(vertical) {
2890
+ return {
2891
+ state: subjectGrantState(this.switchSql(), peerSubjectRef(vertical), new Date().toISOString()),
2892
+ instance: this.instanceId,
2893
+ };
2784
2894
  }
2785
2895
  /**
2786
2896
  * Does peer `vertical` hold each key at this scope's node now (#1706) — the checker's own
@@ -2788,7 +2898,15 @@ export function defineScopeDO(modules, bareOps) {
2788
2898
  * is the target scope only to satisfy the type: `covers` records nothing, and the tuple ref
2789
2899
  * it walks is the slug alone.
2790
2900
  */
2791
- async peerCovers(tenantId, scopeId, vertical, permissions) {
2901
+ async peerCovers(tenantId, scopeId, vertical, permissions,
2902
+ /** #2029: the instance the peer door's gate read; on another, nothing is read. */
2903
+ doorInstance) {
2904
+ if (this.peerDoorMoved(vertical, doorInstance))
2905
+ return SYSTEM_DOOR_MOVED;
2906
+ return this.peerCoverage(tenantId, scopeId, vertical, permissions);
2907
+ }
2908
+ /** `peerCovers`' read, for this object's own callers, which gated the peer themselves. */
2909
+ async peerCoverage(tenantId, scopeId, vertical, permissions) {
2792
2910
  await this.ensureMigrations();
2793
2911
  return this.queue.enqueue(async () => {
2794
2912
  const coverage = await this.checker.covers({ kind: 'vertical', id: vertical, scope: scopeId }, permissions, { tenantId, scopeId });
@@ -2801,9 +2919,11 @@ export function defineScopeDO(modules, bareOps) {
2801
2919
  * so no invoke's open transaction is read half-done. `null` when the tenant defines no such
2802
2920
  * role: a typed error thrown here would reach the host flattened, so the host types it.
2803
2921
  */
2804
- async canAssignFor(tenantId, scopeId, principal, roleKey) {
2922
+ async canAssignFor(tenantId, scopeId, principal, roleKey,
2923
+ /** #1184: bound at the TENANT node instead — a membership executor assigning tenant-wide. */
2924
+ atTenant = false) {
2805
2925
  await this.ensureMigrations();
2806
- return this.queue.enqueue(() => this.assignmentBound({ kind: 'principal', id: principal }, tenantId, scopeId, roleKey));
2926
+ return this.queue.enqueue(() => this.assignmentBound({ kind: 'principal', id: principal }, tenantId, atTenant ? null : scopeId, roleKey));
2807
2927
  }
2808
2928
  /** Check and grant in one serialized scope task; a refusal never writes a tuple. */
2809
2929
  async assignScopeRoleBoundedFor(tenantId, scopeId, caller, assignee, roleKey) {
@@ -2844,7 +2964,9 @@ export function defineScopeDO(modules, bareOps) {
2844
2964
  // the host can tell storage the rewind will discard from storage it restored. On ONE object
2845
2965
  // per deployment, the one named `SWITCH_HOLDS_NAME` and never a scope's own: the claims
2846
2966
  // themselves, because a rewind replaces the rewound scope's whole storage. The claims table
2847
- // is created on first use, so no scope ever carries it.
2967
+ // is created on first use, so no scope ever carries it. #2029: `module_id` holds a hold KEY —
2968
+ // a module id, bare as #1819 stored it, or a peer's `vertical:<slug>`, which no module id can
2969
+ // spell (a module id has no `:`).
2848
2970
  /** This instance, and nothing before or after it: a restart is a new id. */
2849
2971
  instanceId = crypto.randomUUID();
2850
2972
  /** Whether this instance armed a rewind: then its writes are discarded at the restart. */
@@ -3204,9 +3326,16 @@ export function defineScopeDO(modules, bareOps) {
3204
3326
  });
3205
3327
  return row;
3206
3328
  }
3207
- /** One run by id, whatever its status. */
3208
- async jobRunById(id) {
3209
- return (this.sql.exec('SELECT * FROM _substrat_job_runs WHERE id = ?', id).toArray()[0] ?? null);
3329
+ /**
3330
+ * #2034 (#2042 review r1): the FENCE for a coordinator from before leases. Its only caller is
3331
+ * that coordinator's drive, which re-reads a run here and then runs the pass WITHOUT claiming
3332
+ * it, so a row handed back could run beside a claimed pass. It answers "no such run" to it,
3333
+ * always: the old drive skips, and only a coordinator that claims ever drives. Nothing else
3334
+ * reads a run through this method (the operator read is `jobRunList`). The coordinator ships in
3335
+ * the same script as this class, so an old one exists only for a deploy's overlap.
3336
+ */
3337
+ async jobRunById(_id) {
3338
+ return null;
3210
3339
  }
3211
3340
  /** Insert a fresh run. The coordinator has already refused a non-queue-safe payload. */
3212
3341
  async jobRunInsert(row) {
@@ -3216,30 +3345,19 @@ export function defineScopeDO(modules, bareOps) {
3216
3345
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, row.id, row.module_id, row.job, row.instance, row.payload, row.status, row.cursor, row.counters, row.attempts, row.last_error, row.started_at, row.updated_at, row.next_attempt_at, row.ended_at, row.subject_id ?? null);
3217
3346
  }
3218
3347
  /**
3219
- * `running` runs whose backoff has elapsed, oldest first, after `afterId`.
3220
- *
3221
- * The cursor is what lets the coordinator page past runs it cannot drive: it
3222
- * skips any whose job this deployment does not register, and without a cursor
3223
- * those rows head every batch forever (`runDueJobRuns`). `afterId` is LAST, as
3224
- * every argument added to an RPC on this interface must be.
3348
+ * Was: `running` runs whose backoff has elapsed, oldest first, after `afterId` — the due read of
3349
+ * a coordinator from before #1834, which drove every row it returned. Now always empty (below).
3225
3350
  */
3226
- async jobRunsDue(now, limit, afterId) {
3227
- return this.sql
3228
- .exec(
3229
- // `afterId` bound TWICE rather than as `?2`: mixing anonymous and numbered
3230
- // parameters makes the anonymous ones resume from the highest index used,
3231
- // which is a footgun for the next person to add a clause. Spelled exactly
3232
- // as the pure adapter spells it.
3233
- `SELECT * FROM _substrat_job_runs
3234
- WHERE status = 'running' AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
3235
- AND (? IS NULL OR id > ?)
3236
- ORDER BY id LIMIT ?`, now, afterId ?? null, afterId ?? null, limit)
3237
- .toArray();
3351
+ async jobRunsDue(_now, _limit, _afterId) {
3352
+ // #2034 (#2042 review r1): fenced, as `jobRunById` is. Its only caller is a coordinator from
3353
+ // before #1834, which runs every row this returns without claiming it.
3354
+ return [];
3238
3355
  }
3239
3356
  /**
3240
3357
  * #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.
3358
+ * (`JOB_RUN_DUE_AT`, then id). Spelled exactly as the pure adapter spells it. A coordinator
3359
+ * from before #2034 reads this too, but drives nothing from it: its re-read (`jobRunById`)
3360
+ * is fenced.
3243
3361
  */
3244
3362
  async jobRunsDueKeys(now, max) {
3245
3363
  return this.sql
@@ -3278,20 +3396,99 @@ export function defineScopeDO(modules, bareOps) {
3278
3396
  * partial update that wrote three of the four would leave a run carrying the
3279
3397
  * last failure's error beside the new cursor, which reads as broken forever.
3280
3398
  */
3281
- async jobRunPatch(id, patch) {
3282
- // A compare-and-set on `running` (#1632) — see `JOB_RUN_PATCH_SQL`.
3283
- this.sql.exec(JOB_RUN_PATCH_SQL, patch.status, patch.cursor, patch.counters, patch.attempts, patch.lastError, patch.updatedAt, patch.nextAttemptAt, patch.endedAt, id);
3399
+ async jobRunPatch(id, patch, owner) {
3400
+ // A compare-and-set on `running` (#1632) and on the pass's lease (#2034) — see
3401
+ // `JOB_RUN_PATCH_SQL`. `owner` is LAST and optional: a coordinator from before leases
3402
+ // sends none, and patches the unleased rows it drives.
3403
+ return this.sql.exec(JOB_RUN_PATCH_SQL, ...this.jobPatchArgs(id, patch, owner)).rowsWritten > 0;
3404
+ }
3405
+ jobPatchArgs(id, patch, owner) {
3406
+ return [
3407
+ patch.status, patch.cursor, patch.counters, patch.attempts, patch.lastError,
3408
+ patch.updatedAt, patch.nextAttemptAt, patch.endedAt, id, owner ?? null,
3409
+ ];
3410
+ }
3411
+ /**
3412
+ * #2034: the claim — `JOB_RUN_CLAIM_SQL`, and whether it took over a lease, read in ONE
3413
+ * `transactionSync`: the single round trip is what makes it indivisible, as in
3414
+ * `jobRunStartOrJoin`. Null = the run is no longer running and due.
3415
+ */
3416
+ async jobRunClaim(id, owner, leaseMs) {
3417
+ return this.revision.transactionSync(() => {
3418
+ // #2042 r2, r4: a takeover is charged only when the lease it takes had BEGUN its pass.
3419
+ const before = this.sql.exec('SELECT lease_began_at FROM _substrat_job_runs WHERE id = ?', id).toArray()[0];
3420
+ // #2042 r4: the due test and the expiry are THIS object's clock, never the coordinator's.
3421
+ const now = Date.now();
3422
+ const at = new Date(now).toISOString();
3423
+ const claimed = this.sql
3424
+ .exec(JOB_RUN_CLAIM_SQL, owner, new Date(now + leaseMs).toISOString(), at, JOB_LEASE_EXPIRED_NOTE, id, at)
3425
+ .toArray()[0];
3426
+ return claimed ? { run: claimed, takeover: (before?.lease_began_at ?? null) !== null } : null;
3427
+ });
3284
3428
  }
3285
- /** One step's ledger row — a non-null `result` is what means completed. */
3429
+ /**
3430
+ * #2042 r3, r4: an admission miss — `JOB_RUN_MISS_SQL` (the relative count) and
3431
+ * `JOB_RUN_MISS_SETTLE_SQL` (the backoff or failure that count calls for) in ONE transaction,
3432
+ * by this object's clock. Null = the claim no longer held the run, or had begun.
3433
+ */
3434
+ async jobRunMiss(id, owner, note) {
3435
+ return this.revision.transactionSync(() => {
3436
+ const at = new Date(Date.now()).toISOString();
3437
+ const counted = this.sql.exec(JOB_RUN_MISS_SQL, at, id, owner).toArray()[0];
3438
+ if (!counted)
3439
+ return null;
3440
+ const o = admissionMissOutcome(counted.admission_misses, at, note);
3441
+ this.sql.exec(JOB_RUN_MISS_SETTLE_SQL, o.status, o.nextAttemptAt, o.endedAt, o.lastError, id, counted.admission_misses);
3442
+ return { misses: counted.admission_misses, failed: o.status === 'failed' };
3443
+ });
3444
+ }
3445
+ /**
3446
+ * #2034 (#2042 r4): BEGIN a claimed pass — `JOB_RUN_BEGIN_SQL`, the commitment point: one
3447
+ * compare-and-set judged by this object's clock as it runs, never by a time the coordinator
3448
+ * computed. The coordinator invokes the handler if and only if this wrote.
3449
+ */
3450
+ async jobRunBegin(id, owner, marginMs) {
3451
+ const now = Date.now();
3452
+ return (this.sql.exec(JOB_RUN_BEGIN_SQL, new Date(now).toISOString(), id, owner, new Date(now + marginMs).toISOString())
3453
+ .rowsWritten > 0);
3454
+ }
3455
+ /**
3456
+ * #2034: a step boundary — renew the pass's lease, then read the step's ledger row, in one
3457
+ * round trip. A pass that lost its lease reads nothing and stops.
3458
+ */
3459
+ async jobStepBegin(runId, step, owner, leaseMs) {
3460
+ // #2042 r4: renewed to this object's now plus the lease.
3461
+ const until = new Date(Date.now() + leaseMs).toISOString();
3462
+ if (this.sql.exec(JOB_RUN_RENEW_SQL, until, runId, owner).rowsWritten === 0)
3463
+ return { held: false, row: null };
3464
+ return { held: true, row: this.stepRow(runId, step) };
3465
+ }
3466
+ /**
3467
+ * One step's ledger row — a non-null `result` is what means completed. Not called by a
3468
+ * coordinator since #2034 (`jobStepBegin` reads it while renewing); kept for one a deploy behind.
3469
+ */
3286
3470
  async jobStepRow(runId, step) {
3471
+ return this.stepRow(runId, step);
3472
+ }
3473
+ stepRow(runId, step) {
3287
3474
  return (this.sql
3288
3475
  .exec('SELECT step, result, attempts, last_error FROM _substrat_job_steps WHERE run_id = ? AND step = ?', runId, step)
3289
3476
  .toArray()[0] ?? null);
3290
3477
  }
3291
3478
  /** Record one step attempt. `result` non-null = it completed and must not re-run. */
3292
- async jobStepRecord(runId, step, result, attempts, lastError, at) {
3293
- // Only while the run is still `running` (#1632) — see `JOB_STEP_RECORD_SQL`.
3294
- this.sql.exec(JOB_STEP_RECORD_SQL, runId, step, result, attempts, lastError, at, runId);
3479
+ async jobStepRecord(runId, step, result, attempts, lastError, at, owner, leaseMs) {
3480
+ // Only while the run is still `running` (#1632) and the pass holds its lease (#2034),
3481
+ // renewing the lease — to this object's now plus `leaseMs` (#2042 r4) — in the same
3482
+ // transaction; see `JOB_STEP_RECORD_SQL`. A coordinator from before leases sends neither,
3483
+ // holds no lease, and renews nothing.
3484
+ return this.revision.transactionSync(() => {
3485
+ const until = leaseMs === undefined ? null : new Date(Date.now() + leaseMs).toISOString();
3486
+ if (until !== null && this.sql.exec(JOB_RUN_RENEW_SQL, until, runId, owner ?? null).rowsWritten === 0) {
3487
+ return false;
3488
+ }
3489
+ this.sql.exec(JOB_STEP_RECORD_SQL, runId, step, result, attempts, lastError, at, runId, owner ?? null);
3490
+ return true;
3491
+ });
3295
3492
  }
3296
3493
  /**
3297
3494
  * A COMMITTED pass: the run's new state and the dropping of its step ledger, in
@@ -3304,13 +3501,17 @@ export function defineScopeDO(modules, bareOps) {
3304
3501
  * results, not to the cursor) then skips work it never did. The kernel's
3305
3502
  * `runJobPass` carries the full argument.
3306
3503
  */
3307
- async jobCommitPass(id, patch) {
3504
+ async jobCommitPass(id, patch, owner) {
3308
3505
  // `transactionSync` with both statements inline — NOT `await
3309
3506
  // this.jobRunPatch(...)` then the delete. The await is an output-gate
3310
3507
  // boundary, which is precisely the gap this method exists to close.
3311
- this.revision.transactionSync(() => {
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);
3508
+ return this.revision.transactionSync(() => {
3509
+ // #2034: the ledger goes only with a patch that applied — a stale holder's commit must
3510
+ // not empty the ledger of the pass that took the run over.
3511
+ if (this.sql.exec(JOB_RUN_PATCH_SQL, ...this.jobPatchArgs(id, patch, owner)).rowsWritten === 0)
3512
+ return false;
3313
3513
  this.sql.exec('DELETE FROM _substrat_job_steps WHERE run_id = ?', id);
3514
+ return true;
3314
3515
  });
3315
3516
  }
3316
3517
  // -- guards (K-17) --------------------------------------------------------
@@ -3763,13 +3964,26 @@ export function defineScopeDO(modules, bareOps) {
3763
3964
  system: isSystemTable(name),
3764
3965
  }));
3765
3966
  }
3766
- /** A bounded page of one table. Unknown table names throw — never queried blind. */
3967
+ /** A bounded page of one table. Unknown table names throw — never queried blind. Kept for a
3968
+ * coordinator from before #113; the current one calls `introspectTableReply`. */
3767
3969
  introspectTable(table, limit, offset) {
3970
+ try {
3971
+ return this.tablePage(table, limit, offset);
3972
+ }
3973
+ catch (e) {
3974
+ throw toRpcError(e);
3975
+ }
3976
+ }
3977
+ /** `introspectTable`, its refusal answered as DATA so its code survives the hop (#113). */
3978
+ introspectTableReply(table, limit, offset) {
3979
+ return replyOf(() => this.tablePage(table, limit, offset));
3980
+ }
3981
+ tablePage(table, limit, offset) {
3768
3982
  const known = new Set(this.sql
3769
3983
  .exec(`SELECT name FROM sqlite_master WHERE type = 'table'`)
3770
3984
  .toArray().map((r) => r.name));
3771
3985
  if (!known.has(table))
3772
- throw new Error(`unknown table '${table}'`);
3986
+ throw substratError('not_found', `unknown table '${table}'`);
3773
3987
  // The ceiling clamps; a bound SQLite would misread (NaN, non-finite, fractional,
3774
3988
  // negative) is refused instead of reaching LIMIT / OFFSET (#1632).
3775
3989
  const l = Math.min(assertRowLimit('limit', limit), SCOPE_TABLE_PAGE_MAX);
@@ -3788,8 +4002,21 @@ export function defineScopeDO(modules, bareOps) {
3788
4002
  * (no sqlite3_stmt_readonly analogue), a transaction that ALWAYS rolls back — a
3789
4003
  * statement the gate misclassified still cannot persist a write. Rows are capped
3790
4004
  * at SCOPE_QUERY_ROW_MAX with `truncated` set, never an error.
4005
+ * Kept for a coordinator from before #113; the current one calls `introspectQueryReply`.
3791
4006
  */
3792
4007
  async introspectQuery(sql) {
4008
+ try {
4009
+ return await this.readOnlyQuery(sql);
4010
+ }
4011
+ catch (e) {
4012
+ throw toRpcError(e);
4013
+ }
4014
+ }
4015
+ /** `introspectQuery`, its refusal answered as DATA so its code survives the hop (#113). */
4016
+ introspectQueryReply(sql) {
4017
+ return replyOf(() => this.readOnlyQuery(sql));
4018
+ }
4019
+ async readOnlyQuery(sql) {
3793
4020
  const stmt = assertReadOnlyQuery(sql);
3794
4021
  let result;
3795
4022
  const rollback = new Error('read-only console rollback');
@@ -3813,7 +4040,7 @@ export function defineScopeDO(modules, bareOps) {
3813
4040
  }
3814
4041
  catch (e) {
3815
4042
  if (e !== rollback)
3816
- throw toRpcError(e);
4043
+ throw e;
3817
4044
  }
3818
4045
  return result;
3819
4046
  }
@@ -3934,6 +4161,12 @@ export function defineScopeDO(modules, bareOps) {
3934
4161
  'ALTER TABLE _substrat_tuples ADD COLUMN revoked_at TEXT',
3935
4162
  // #1632: legacy runs retain an unknown subject; no content-based backfill.
3936
4163
  'ALTER TABLE _substrat_job_runs ADD COLUMN subject_id TEXT',
4164
+ // #2034: the lease. NULL = nobody holds the run, which is right for every row already there.
4165
+ 'ALTER TABLE _substrat_job_runs ADD COLUMN lease_owner TEXT',
4166
+ // #2042 r2, r4: whether the holder BEGAN its pass. NULL for any lease already there: free to take over.
4167
+ 'ALTER TABLE _substrat_job_runs ADD COLUMN lease_began_at TEXT',
4168
+ // #2042 r3: consecutive admission misses. NULL = none, right for every run already there.
4169
+ 'ALTER TABLE _substrat_job_runs ADD COLUMN admission_misses INTEGER',
3937
4170
  // Executor retry state (#100). The defaults read as "terminal", which is
3938
4171
  // right for every row already there: each is a completed delivery or a
3939
4172
  // consumer dead-letter.
@@ -5209,6 +5442,10 @@ export function defineScopeDO(modules, bareOps) {
5209
5442
  this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES ('permission_source', ?)`, source);
5210
5443
  });
5211
5444
  }
5445
+ /** `applyProjection`, its refusal answered as DATA so its code survives the hop (#113). */
5446
+ applyProjectionReply(...args) {
5447
+ return replyOf(() => this.applyProjection(...args));
5448
+ }
5212
5449
  /**
5213
5450
  * Replace this scope's projected view of a tenant's roles + tenant-level tuples
5214
5451
  * with a fresh snapshot, and make it authoritative (source = 'local'). A full