@substrat-run/adapter-cloudflare 0.180.0 → 0.183.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/host.js CHANGED
@@ -50,15 +50,18 @@ export const TENANT_UNRECORDED_PER_PASS = 50;
50
50
  */
51
51
  const unknownScopeForTenant = (tenantId, scopeId) => substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
52
52
  /**
53
- * #2016: the line a CP-less host writes each time it admits a pair to a scope with no tenant record
54
- * and no role rows — nothing to hold the pair against. Ids only.
53
+ * #2035: the line a CP-less host writes when a door refuses a scope whose tenant is not recorded —
54
+ * no receipt and no role rows. The caller gets the foreign pair's refusal unchanged, so this line is
55
+ * the only place an operator tells a scope that has not converged from a wrong-tenant request.
56
+ * Server-side only, and ids and the door's name only.
55
57
  */
56
- export const tenantUnrecordedLine = (tenantId, scopeId) => ({
57
- substrat: 'tenant-unrecorded',
58
+ const tenantUnrecordedRefusedLine = (tenantId, scopeId, door) => ({
59
+ substrat: 'tenant-unrecorded-refused',
58
60
  level: 'warn',
59
61
  tenantId,
60
62
  scopeId,
61
- message: 'admitted to a scope whose tenant is not recorded; a reconcile or lifecycle delivery records it',
63
+ door,
64
+ message: 'refused a scope whose tenant is not recorded; a reconcile, provision or load naming its tenant records it',
62
65
  });
63
66
  /**
64
67
  * #2430: the line a host writes when a provision or reconcile skips delivered connection grants that
@@ -828,7 +831,7 @@ export class CloudflareScopeHost {
828
831
  async drainDue(tenantId, scopeId) {
829
832
  // Same lifecycle gate `getScope` applies (K-3): a suspended or archived scope
830
833
  // does not get its effects driven either.
831
- await this.assertLive(tenantId, scopeId);
834
+ await this.assertLive(tenantId, scopeId, 'drainDue');
832
835
  await this.migrateAndRecord(scopeId);
833
836
  // #1525: null, and honestly so — a sweep is not a call. An attempt this pass makes
834
837
  // records no invocation, which is what distinguishes it from the first attempt the
@@ -889,14 +892,14 @@ export class CloudflareScopeHost {
889
892
  this.attachmentExtractors, this.attachmentTextBounds);
890
893
  }
891
894
  async startJobRun(tenantId, scopeId, input) {
892
- await this.assertLive(tenantId, scopeId);
895
+ await this.assertLive(tenantId, scopeId, 'startJobRun');
893
896
  await this.migrateAndRecord(scopeId);
894
897
  return jobRunOf(await startJobRun(this.jobStore(scopeId), input, ulid, () => new Date().toISOString()));
895
898
  }
896
899
  async runDueJobs(tenantId, scopeId, options) {
897
900
  // Same lifecycle gate as `drainDue`: a suspended scope's runs wait rather than
898
901
  // advance, and an archived one's never move again.
899
- await this.assertLive(tenantId, scopeId);
902
+ await this.assertLive(tenantId, scopeId, 'runDueJobs');
900
903
  await this.migrateAndRecord(scopeId);
901
904
  // #1575: the kernel's extraction jobs are this host's own, bound to this scope — no
902
905
  // deployment registers them, and none can shadow them. The backfill starts here, on
@@ -920,7 +923,7 @@ export class CloudflareScopeHost {
920
923
  });
921
924
  }
922
925
  async jobRuns(tenantId, scopeId, filter) {
923
- await this.validateScopeAccess(tenantId, scopeId);
926
+ await this.validateScopeAccess(tenantId, scopeId, 'jobRuns');
924
927
  await this.migrateAndRecord(scopeId);
925
928
  return (await this.jobStore(scopeId).list(filter ?? {})).map(jobRunOf);
926
929
  }
@@ -930,13 +933,13 @@ export class CloudflareScopeHost {
930
933
  // same context build as the in-process path, so the handler cannot tell which host
931
934
  // ran it. On a CP-less host `connectorContext` throws from the null control plane:
932
935
  // fail closed, exactly the hole routing exists to avoid.
933
- await this.assertLive(tenantId, scopeId);
936
+ await this.assertLive(tenantId, scopeId, 'dispatchConnector');
934
937
  // Built on a view bound to the event, so its admin rows carry it and nothing else the
935
938
  // host serves does (#2055).
936
939
  await handler(await this.causedByView(event.id).connectorContext(tenantId, scopeId, options?.timeoutMs ?? 30_000, event.id), event);
937
940
  }
938
941
  async executorDeadLetters(tenantId, scopeId) {
939
- await this.validateScopeAccess(tenantId, scopeId);
942
+ await this.validateScopeAccess(tenantId, scopeId, 'executorDeadLetters');
940
943
  await this.migrateAndRecord(scopeId);
941
944
  return this.scopeStub(scopeId).executorDeadLetters();
942
945
  }
@@ -949,18 +952,18 @@ export class CloudflareScopeHost {
949
952
  return this.scopeStub(scopeId).enqueueSweepRuns(JSON.stringify(sweepRunsPayload.parse(payload)), JSON.stringify({ system: 'scope-sweeper' }));
950
953
  }
951
954
  async listPlatformRequests(tenantId, scopeId) {
952
- await this.validateScopeAccess(tenantId, scopeId);
955
+ await this.validateScopeAccess(tenantId, scopeId, 'listPlatformRequests');
953
956
  await this.migrateAndRecord(scopeId);
954
957
  // Tolerant (#1588, #1637): an undecodable row comes back naming why, never throws for the list.
955
958
  return (await this.scopeStub(scopeId).pendingPlatformRequests()).map(platformRequestOf);
956
959
  }
957
960
  async listPlatformRequestHistory(tenantId, scopeId, filter) {
958
- await this.validateScopeAccess(tenantId, scopeId);
961
+ await this.validateScopeAccess(tenantId, scopeId, 'listPlatformRequestHistory');
959
962
  await this.migrateAndRecord(scopeId);
960
963
  return (await this.scopeStub(scopeId).platformRequestHistory(filter)).map(platformRequestOf);
961
964
  }
962
965
  async settlePlatformRequest(tenantId, scopeId, id, outcome) {
963
- await this.validateScopeAccess(tenantId, scopeId);
966
+ await this.validateScopeAccess(tenantId, scopeId, 'settlePlatformRequest');
964
967
  await this.migrateAndRecord(scopeId);
965
968
  // #2102: an outcome event travels by the verb that writes it; every other settle keeps the
966
969
  // positional one, so it still reaches a DO class that predates the event.
@@ -1095,7 +1098,7 @@ export class CloudflareScopeHost {
1095
1098
  // Checked on this side too, as `HostAdmin.mintCapability` does: a typed refusal thrown
1096
1099
  // across the RPC arrives as a bare message.
1097
1100
  checkBecomeInput(input, new Date().toISOString());
1098
- await this.assertLive(tenantId, scopeId);
1101
+ await this.assertLive(tenantId, scopeId, 'mintCapabilityLocal');
1099
1102
  await this.migrateAndRecord(scopeId);
1100
1103
  return mintedCapability.parse(await this.scopeStub(scopeId).mintBecomeCapability(input, actor));
1101
1104
  }
@@ -1674,7 +1677,7 @@ export class CloudflareScopeHost {
1674
1677
  after = { scriptRef: last.scriptRef, moveId: last.moveId };
1675
1678
  }
1676
1679
  // The tenant's status too, as every door into a scope asks.
1677
- await this.assertLive(tenantId, scopeId);
1680
+ await this.assertLive(tenantId, scopeId, 'sweepOrphanAttachments');
1678
1681
  vouched = true;
1679
1682
  }
1680
1683
  else if (request.copiesSettled !== true) {
@@ -1993,18 +1996,18 @@ export class CloudflareScopeHost {
1993
1996
  // gates and metadata facts live in the ScopeDO (per-scope serialization, spine event
1994
1997
  // in the same transaction); bytes go straight to the per-tenant R2 bucket through the
1995
1998
  // binding the vertical's worker resolved — never through the DO.
1996
- await this.assertLive(tenantId, scopeId);
1999
+ await this.assertLive(tenantId, scopeId, 'attachments');
1997
2000
  await this.migrateAndRecord(scopeId);
1998
2001
  const store = await this.resolveAttachmentStore(tenantId);
1999
2002
  return this.buildAttachmentSurface({ principal }, tenantId, scopeId, store);
2000
2003
  }
2001
2004
  async inspectUploadLink(tenantId, scopeId, secret, target, idempotencyKey) {
2002
- await this.assertLive(tenantId, scopeId);
2005
+ await this.assertLive(tenantId, scopeId, 'inspectUploadLink');
2003
2006
  await this.migrateAndRecord(scopeId);
2004
2007
  return unwrapReply(await this.scopeStub(scopeId).uploadLinkInspectReply(secret, tenantId, scopeId, target, idempotencyKey)).view;
2005
2008
  }
2006
2009
  async redeemUploadLink(tenantId, scopeId, secret, input) {
2007
- await this.assertLive(tenantId, scopeId);
2010
+ await this.assertLive(tenantId, scopeId, 'redeemUploadLink');
2008
2011
  await this.migrateAndRecord(scopeId);
2009
2012
  const stub = this.scopeStub(scopeId);
2010
2013
  // The target first (the kernel judges it before the state), so a wrong path never reads as
@@ -2057,7 +2060,7 @@ export class CloudflareScopeHost {
2057
2060
  throw new Error(`connection ${connectionId} is for vertical '${conn.vertical}' and scope ${scopeId} ` +
2058
2061
  `runs '${scope.vertical ?? 'none'}'`);
2059
2062
  }
2060
- await this.validateScopeAccess(conn.tenant_id, scopeId);
2063
+ await this.validateScopeAccess(conn.tenant_id, scopeId, 'getConnectorAttachments');
2061
2064
  // #574: same delegation as getConnectorScope. `upload` is the verb the reconcile
2062
2065
  // path needs (landing the sealed PDF) and `open` the one the outbound path needs
2063
2066
  // (sending the vertical's own document, #711). `list` and `remove` still fail
@@ -2458,7 +2461,7 @@ export class CloudflareScopeHost {
2458
2461
  async getScope(principal, tenantId, scopeId, options) {
2459
2462
  // Lifecycle gates (control-plane.md §4.1/§4.2), the K-3 fail-closed path,
2460
2463
  // evaluated durably in the ControlPlaneDO. A throw propagates here.
2461
- await this.assertLive(tenantId, scopeId);
2464
+ await this.assertLive(tenantId, scopeId, 'getScope');
2462
2465
  await this.migrateAndRecord(scopeId);
2463
2466
  return this.buildStub(tenantId, scopeId, principal, undefined, undefined, options);
2464
2467
  }
@@ -2495,7 +2498,7 @@ export class CloudflareScopeHost {
2495
2498
  throw substratError('not_found', `no scope store is bound for '${binding}'`);
2496
2499
  }
2497
2500
  const vertical = await this.scopeStoreVerticalOf(tenantId, scopeId);
2498
- await this.assertLive(tenantId, scopeId);
2501
+ await this.assertLive(tenantId, scopeId, 'getScopeStore');
2499
2502
  if (runner) {
2500
2503
  await this.scopeStoreLifecycle.start();
2501
2504
  return {
@@ -2587,7 +2590,7 @@ export class CloudflareScopeHost {
2587
2590
  await this.scopeRecordForRead(tenantId, scopeId);
2588
2591
  const vertical = await this.scopeStoreVerticalOf(tenantId, scopeId);
2589
2592
  if (this.cpLess)
2590
- await this.assertLive(tenantId, scopeId);
2593
+ await this.assertLive(tenantId, scopeId, 'readScopeStoreSizes');
2591
2594
  const sizes = [];
2592
2595
  for (const binding of bindings) {
2593
2596
  const { bytes } = await this.invokeReservedStore({ tenantId, vertical, scopeId, binding }, 'substrat.size');
@@ -2792,7 +2795,7 @@ export class CloudflareScopeHost {
2792
2795
  throw new Error(`connection ${connectionId} is for vertical '${conn.vertical}' and scope ${scopeId} ` +
2793
2796
  `runs '${scope.vertical ?? 'none'}'`);
2794
2797
  }
2795
- await this.validateScopeAccess(conn.tenant_id, scopeId);
2798
+ await this.validateScopeAccess(conn.tenant_id, scopeId, 'getConnectorScope');
2796
2799
  // #574: a scope served by ANOTHER deployment (the shared control plane running the
2797
2800
  // connector pass for a dispatch vertical) — the write-back rides the delegation
2798
2801
  // seam; migration is the serving deployment's business, exactly like provision.
@@ -2839,7 +2842,7 @@ export class CloudflareScopeHost {
2839
2842
  const record = await this.resolveImpersonation(session, tenantId, scopeId);
2840
2843
  // The same lifecycle gate the principal door applies: a suspended tenant
2841
2844
  // refuses a support session exactly as it refuses a user.
2842
- await this.assertLive(tenantId, scopeId);
2845
+ await this.assertLive(tenantId, scopeId, 'getImpersonatedScope');
2843
2846
  await this.migrateAndRecord(scopeId);
2844
2847
  return this.buildStub(tenantId, scopeId, record.principal, undefined, undefined, options, record.id);
2845
2848
  }
@@ -2873,7 +2876,7 @@ export class CloudflareScopeHost {
2873
2876
  if (!this.moduleIds.has(moduleId)) {
2874
2877
  throw substratError('not_found', `module not registered on this host: ${moduleId}`);
2875
2878
  }
2876
- await this.assertLive(tenantId, scopeId);
2879
+ await this.assertLive(tenantId, scopeId, 'openSystemDoor');
2877
2880
  await this.migrateAndRecord(scopeId);
2878
2881
  return this.openDoor('system', moduleId, scopeId, gated, pass);
2879
2882
  }
@@ -3025,7 +3028,7 @@ export class CloudflareScopeHost {
3025
3028
  * capability row lives. The answer is re-parsed on this side of the RPC.
3026
3029
  */
3027
3030
  async exchangeCapability(tenantId, scopeId, secret, options) {
3028
- await this.assertLive(tenantId, scopeId);
3031
+ await this.assertLive(tenantId, scopeId, 'exchangeCapability');
3029
3032
  await this.migrateAndRecord(scopeId);
3030
3033
  const outcome = await this.scopeStub(scopeId).exchangeCapability(secret, tenantId, scopeId, options?.mode, options?.mode === 'bind' ? principalId.parse(options.presenter) : undefined);
3031
3034
  return capabilityExchange.nullable().parse(outcome);
@@ -3039,7 +3042,7 @@ export class CloudflareScopeHost {
3039
3042
  if (!plausibleSessionToken(sessionToken)) {
3040
3043
  throw substratError('unauthenticated', 'not a capability session token');
3041
3044
  }
3042
- await this.assertLive(tenantId, scopeId);
3045
+ await this.assertLive(tenantId, scopeId, 'getCapabilityScope');
3043
3046
  await this.migrateAndRecord(scopeId);
3044
3047
  const hash = await capabilityTokenHash(sessionToken);
3045
3048
  return this.buildStub(tenantId, scopeId, undefined, undefined, undefined, options, undefined, hash);
@@ -3214,7 +3217,7 @@ export class CloudflareScopeHost {
3214
3217
  }
3215
3218
  this.assertServedHere(record, scopeId, verb);
3216
3219
  }
3217
- await this.assertLive(tenantId, scopeId);
3220
+ await this.assertLive(tenantId, scopeId, 'peerScopeGate');
3218
3221
  await this.migrateAndRecord(scopeId);
3219
3222
  return record;
3220
3223
  }
@@ -3244,7 +3247,7 @@ export class CloudflareScopeHost {
3244
3247
  if (!plausibleSessionToken(sessionToken)) {
3245
3248
  throw substratError('unauthenticated', 'not a capability session token');
3246
3249
  }
3247
- await this.assertLive(tenantId, scopeId);
3250
+ await this.assertLive(tenantId, scopeId, 'getCapabilityAttachments');
3248
3251
  await this.migrateAndRecord(scopeId);
3249
3252
  const store = await this.resolveAttachmentStore(tenantId);
3250
3253
  const capabilitySession = await capabilityTokenHash(sessionToken);
@@ -3351,7 +3354,7 @@ export class CloudflareScopeHost {
3351
3354
  if (await this.cp.scopeAccessRefusal(tenantId, scopeId))
3352
3355
  return report;
3353
3356
  }
3354
- else if (lifecycleRefusal(await this.validateScopeAccess(tenantId, scopeId)) !== null) {
3357
+ else if (lifecycleRefusal(await this.validateScopeAccess(tenantId, scopeId, 'checkFreshness')) !== null) {
3355
3358
  // #2016: the pair is held to the scope's own record first, so a sweep roster entry whose
3356
3359
  // tenant disagrees with the scope is refused before the probe reads or writes anything.
3357
3360
  // #1713: a held scope emits nothing, so judging it stale would only raise a false alarm.
@@ -3404,7 +3407,7 @@ export class CloudflareScopeHost {
3404
3407
  if (await this.cp.scopeAccessRefusal(tenantId, scopeId))
3405
3408
  return report;
3406
3409
  }
3407
- else if (lifecycleRefusal(await this.validateScopeAccess(tenantId, scopeId)) !== null) {
3410
+ else if (lifecycleRefusal(await this.validateScopeAccess(tenantId, scopeId, 'runDueSchedules')) !== null) {
3408
3411
  // #1713: the lifecycle the platform delivered holds the scope. Every schedule is
3409
3412
  // `skipped` and no cadence row moves, as under the kill switch, so a schedule that
3410
3413
  // came due meanwhile fires once on the first pass after the scope is live again.
@@ -3475,7 +3478,7 @@ export class CloudflareScopeHost {
3475
3478
  // read again before every later fire: the scope does not hold a directory lifecycle,
3476
3479
  // so a suspension after an earlier fire would otherwise not stop this one (#1713).
3477
3480
  if (door)
3478
- await this.assertLive(tenantId, scopeId);
3481
+ await this.assertLive(tenantId, scopeId, 'runDueSchedules');
3479
3482
  door ??= await this.openSystemDoor(moduleId, tenantId, scopeId, gate);
3480
3483
  const scope = this.buildStub(tenantId, scopeId, undefined, undefined, door, counted);
3481
3484
  if (schedule.purge) {
@@ -4040,7 +4043,7 @@ export class CloudflareScopeHost {
4040
4043
  // Without one, a hosted scope is refused `unavailable` rather than switched in the
4041
4044
  // placeholder namespace, where it would tombstone nothing a peer call reads.
4042
4045
  if (delegated)
4043
- await this.validateScopeAccess(tenantId, scopeId);
4046
+ await this.validateScopeAccess(tenantId, scopeId, 'switchSubject');
4044
4047
  else
4045
4048
  await this.peerScopeGate(tenantId, scopeId, action);
4046
4049
  }
@@ -4830,7 +4833,7 @@ export class CloudflareScopeHost {
4830
4833
  // a bare message, and the caller would lose `validation_failed`. One function, so the
4831
4834
  // two sides cannot disagree about what a valid mint is.
4832
4835
  checkBecomeInput(input, new Date().toISOString());
4833
- await this.validateScopeAccess(tenantId, scopeId);
4836
+ await this.validateScopeAccess(tenantId, scopeId, 'mintCapability');
4834
4837
  const rec = await this.capabilityScopeRecord(tenantId, scopeId);
4835
4838
  // A hosted refusal precedes intent; the local migrate happens inside the audited write.
4836
4839
  this.assertCapabilityScopeReach(rec, scopeId, 'mintCapability');
@@ -7062,7 +7065,9 @@ export class CloudflareScopeHost {
7062
7065
  * without the lifecycle half pays the one DO call the pair check needs. Null with a directory,
7063
7066
  * whose refusal already covered the lifecycle.
7064
7067
  */
7065
- async validateScopeAccess(tenantId, scopeId) {
7068
+ async validateScopeAccess(tenantId, scopeId,
7069
+ /** The host method the call came through, named in `tenantUnrecordedRefusedLine` only. */
7070
+ door) {
7066
7071
  const refusal = await this.cp.scopeAccessRefusal(tenantId, scopeId);
7067
7072
  if (refusal) {
7068
7073
  throw refusal.code
@@ -7072,14 +7077,15 @@ export class CloudflareScopeHost {
7072
7077
  if (!this.cpLess)
7073
7078
  return null;
7074
7079
  const { verdict, lifecycle } = await this.scopeStub(scopeId).admission(tenantId);
7075
- if (verdict === 'foreign')
7076
- throw unknownScopeForTenant(tenantId, scopeId);
7077
- // A scope with no tenant record and no role rows has nothing to hold the pair against, so it is
7078
- // let through — a compatibility state, not a steady one: the next reconcile (every push runs
7079
- // one against the scopes behind it, #1172) or lifecycle delivery records the directory's tenant.
7080
- // Logged on every admission, so an operator sees a scope that has not converged.
7080
+ // #2035: a scope with no tenant record and no role rows has nothing to hold the pair against, so
7081
+ // it is refused like a foreign one. The platform's paths record the tenant without passing this
7082
+ // door — a provision or reconcile (every push runs one, #1172), a restore or snapshot, a
7083
+ // lifecycle delivery to a scope holding data — so a scope converges rather than staying shut.
7084
+ // The caller meets the foreign pair's refusal, word for word; only the log tells them apart.
7081
7085
  if (verdict === 'unknown')
7082
- console.warn(JSON.stringify(tenantUnrecordedLine(tenantId, scopeId)));
7086
+ console.warn(JSON.stringify(tenantUnrecordedRefusedLine(tenantId, scopeId, door)));
7087
+ if (verdict === 'foreign' || verdict === 'unknown')
7088
+ throw unknownScopeForTenant(tenantId, scopeId);
7083
7089
  return lifecycle;
7084
7090
  }
7085
7091
  /**
@@ -7097,8 +7103,8 @@ export class CloudflareScopeHost {
7097
7103
  * The admin and diagnostic reads (dead letters, job runs, platform requests, delivered
7098
7104
  * grants) keep `validateScopeAccess` alone: an operator still reads a suspended scope.
7099
7105
  */
7100
- async assertLive(tenantId, scopeId) {
7101
- const refusal = lifecycleRefusal(await this.validateScopeAccess(tenantId, scopeId), { tenantId, scopeId });
7106
+ async assertLive(tenantId, scopeId, door) {
7107
+ const refusal = lifecycleRefusal(await this.validateScopeAccess(tenantId, scopeId, door), { tenantId, scopeId });
7102
7108
  if (refusal)
7103
7109
  throw substratError('conflict', refusal, { reason: SCOPE_GATE_REASONS.notActive });
7104
7110
  }
@@ -7364,7 +7370,7 @@ export class CloudflareScopeHost {
7364
7370
  * directory's view stays on `HostAdmin.listConnectionGrants`.
7365
7371
  */
7366
7372
  async connectionGrantsInScope(tenantId, scopeId) {
7367
- await this.validateScopeAccess(tenantId, scopeId);
7373
+ await this.validateScopeAccess(tenantId, scopeId, 'connectionGrantsInScope');
7368
7374
  if (this.connectorDelegation) {
7369
7375
  if (!this.connectorDelegation.grants)
7370
7376
  throw new Error('connector grant inspection is not configured for the serving deployment');
@@ -7377,10 +7383,11 @@ export class CloudflareScopeHost {
7377
7383
  // CP-less shape); a scope with a live control plane has its tenant-level grants in
7378
7384
  // the directory instead, where the checker reads them from (`cp.tenantTuples`). Read
7379
7385
  // only the DO and a tenant-wide grant would be reported absent on exactly the
7380
- // deployments that have a control plane — the fleet — while being enforced.
7386
+ // deployments that have a control plane — the fleet — while being enforced. A CP-less
7387
+ // host has no directory to ask, and its DO already holds the projection (#2892).
7381
7388
  const rows = [
7382
7389
  ...(await this.scopeStub(scopeId).listConnectionGrants(now)),
7383
- ...(await this.cp.dumpTenantTuples(tenantId))
7390
+ ...(this.cpLess ? [] : await this.cp.dumpTenantTuples(tenantId))
7384
7391
  .filter((r) => r.subject.startsWith('connection:') &&
7385
7392
  r.relation.startsWith('granted:') &&
7386
7393
  r.revoked_at === null &&
@@ -7858,7 +7865,7 @@ export class CloudflareScopeHost {
7858
7865
  * count to the directory — so a scope whose first contact after a deploy is a
7859
7866
  * subscription does not go dark in the migration fleet view.
7860
7867
  */
7861
- await this.assertLive(tenantId, scopeId);
7868
+ await this.assertLive(tenantId, scopeId, 'subscribe');
7862
7869
  await this.migrateAndRecord(scopeId);
7863
7870
  // Asserted, not carried through from the client: the principal is the
7864
7871
  // vertical's own resolution of its session, and the tenant and scope are the
@@ -8403,17 +8410,25 @@ export class CloudflareScopeHost {
8403
8410
  * and a switched-off subject gets nothing. It can therefore only add what the running code
8404
8411
  * declares, and never takes anything away but a recorded OFF.
8405
8412
  *
8406
- * Only a scope already provisioned for `tenantId` is seated (`servesTenant`, K-3): this never
8407
- * provisions a scope, and never writes into another tenant's.
8413
+ * Only a scope that serves `tenantId` is seated (K-3): this never provisions a scope, and never
8414
+ * writes into another tenant's. Like a full reconcile, it records the platform's tenant on a scope
8415
+ * that holds no receipt yet (`seatsTenant`, #2035), so a push converges an ownerless vertical's
8416
+ * scopes too — every door refuses a scope whose tenant nothing recorded. The receipt lands in the
8417
+ * seat's own unit, after the recorded-off positions: until then every door still refuses the
8418
+ * scope, and a seat that fails records nothing.
8408
8419
  */
8409
8420
  async seatScopeLocal(input) {
8410
8421
  const stub = this.scopeStub(input.scopeId);
8411
- if (!(await stub.servesTenant(input.tenantId)))
8422
+ if (!(await stub.seatsTenant(input.tenantId)))
8412
8423
  throw unknownScopeForTenant(input.tenantId, input.scopeId);
8413
8424
  const carry = recordedOffFromWire(input);
8414
8425
  const connections = await this.seatableConnectionGrants(input.tenantId, input.scopeId, input.connectionGrants); // #2430
8415
8426
  await this.migrateAndRecord(input.scopeId);
8416
- const switchedOff = await stub.seatTuples(this.declaredSeatsLocal(input.scopeId, connections.seat), carry ? { scopeId: input.scopeId, at: new Date().toISOString(), ...carry } : undefined);
8427
+ const seated = await stub.seatTuplesFor(input.tenantId, this.declaredSeatsLocal(input.scopeId, connections.seat), carry ? { scopeId: input.scopeId, at: new Date().toISOString(), ...carry } : undefined);
8428
+ // Reaped or recorded for another tenant since the check: K-3's refusal, naming no other tenant.
8429
+ if ('refused' in seated)
8430
+ throw unknownScopeForTenant(input.tenantId, input.scopeId);
8431
+ const { switchedOff } = seated;
8417
8432
  return {
8418
8433
  ...(carry ? { switchedOff } : {}),
8419
8434
  ...(connections.skipped.length ? { skippedConnectionGrants: connections.skipped } : {}),
@@ -8601,7 +8616,7 @@ export class CloudflareScopeHost {
8601
8616
  // scope (#1713): a held scope's invoke and bytes doors refuse, as every other door does.
8602
8617
  /** Invoke ONE operation in this deployment as a CONNECTION (#574). */
8603
8618
  async connectorInvokeLocal(connectionId, tenantId, scopeId, operation, input) {
8604
- await this.assertLive(tenantId, scopeId); // #1713: a held scope runs no connector work either
8619
+ await this.assertLive(tenantId, scopeId, 'connectorInvokeLocal'); // #1713: a held scope runs no connector work either
8605
8620
  await this.migrateAndRecord(scopeId);
8606
8621
  return this.buildStub(tenantId, scopeId, undefined, connectionId).invoke(operation, input);
8607
8622
  }
@@ -8609,7 +8624,7 @@ export class CloudflareScopeHost {
8609
8624
  async connectorAttachmentUploadLocal(connectionId, tenantId, scopeId, upload) {
8610
8625
  // The lifecycle gate every attachments door takes (#1713), ahead of the bucket: a held
8611
8626
  // scope's provider bytes wait for the delivery's retry rather than land (#1995).
8612
- await this.assertLive(tenantId, scopeId);
8627
+ await this.assertLive(tenantId, scopeId, 'connectorAttachmentUploadLocal');
8613
8628
  await this.migrateAndRecord(scopeId);
8614
8629
  const store = await this.resolveAttachmentStore(tenantId);
8615
8630
  return this.buildAttachmentSurface({ connectionId }, tenantId, scopeId, store).upload(upload);
@@ -8634,7 +8649,7 @@ export class CloudflareScopeHost {
8634
8649
  /** The attachment surface the platform's reads as a connection go through, behind their gates. */
8635
8650
  async connectorReadSurface(connectionId, tenantId, scopeId, eventId) {
8636
8651
  // Same gate as the upload leg: a held scope's bytes are not read out of its bucket.
8637
- await this.assertLive(tenantId, scopeId);
8652
+ await this.assertLive(tenantId, scopeId, 'connectorReadSurface');
8638
8653
  await this.migrateAndRecord(scopeId);
8639
8654
  const store = await this.resolveAttachmentStore(tenantId);
8640
8655
  return this.buildAttachmentSurface({ connectionId }, tenantId, scopeId, store,
@@ -8808,7 +8823,7 @@ export class CloudflareScopeHost {
8808
8823
  const delegation = rec.vertical !== null ? this.importCursorDelegation : undefined;
8809
8824
  if (!delegation)
8810
8825
  this.assertServedHere(rec, scopeId, 'moveImportCursor');
8811
- await this.validateScopeAccess(tenantId, scopeId);
8826
+ await this.validateScopeAccess(tenantId, scopeId, 'moveImportCursorAt');
8812
8827
  // #2114: the replay's id is also its audit `operationId`, so the scheduled settle pairs and
8813
8828
  // closes the lever's rows exactly as it does the kill switches'.
8814
8829
  const replayId = ulid();