@substrat-run/control-plane-api 0.177.0 → 0.179.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/aggregate-reads.d.ts.map +1 -1
  2. package/dist/aggregate-reads.js +4 -3
  3. package/dist/aggregate-reads.js.map +1 -1
  4. package/dist/aggregate-source.d.ts +8 -1
  5. package/dist/aggregate-source.d.ts.map +1 -1
  6. package/dist/aggregate-source.js +2 -1
  7. package/dist/aggregate-source.js.map +1 -1
  8. package/dist/api.d.ts +4 -0
  9. package/dist/api.d.ts.map +1 -1
  10. package/dist/api.js +435 -296
  11. package/dist/api.js.map +1 -1
  12. package/dist/backups.d.ts +58 -13
  13. package/dist/backups.d.ts.map +1 -1
  14. package/dist/cf-observability.d.ts.map +1 -1
  15. package/dist/cf-observability.js +26 -16
  16. package/dist/cf-observability.js.map +1 -1
  17. package/dist/directory-backup.d.ts +4 -0
  18. package/dist/directory-backup.d.ts.map +1 -1
  19. package/dist/directory-backup.js +60 -7
  20. package/dist/directory-backup.js.map +1 -1
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/lake-gateway.d.ts +9 -0
  25. package/dist/lake-gateway.d.ts.map +1 -1
  26. package/dist/lake-gateway.js +18 -1
  27. package/dist/lake-gateway.js.map +1 -1
  28. package/dist/observability.d.ts +6 -1
  29. package/dist/observability.d.ts.map +1 -1
  30. package/dist/observability.js +8 -1
  31. package/dist/observability.js.map +1 -1
  32. package/dist/platform-entry.generated.d.ts +1 -1
  33. package/dist/platform-entry.generated.d.ts.map +1 -1
  34. package/dist/platform-entry.generated.js +2 -2
  35. package/dist/platform-entry.generated.js.map +1 -1
  36. package/dist/platform-scope-stores.d.ts +20 -22
  37. package/dist/platform-scope-stores.d.ts.map +1 -1
  38. package/dist/platform-scope-stores.js +22 -17
  39. package/dist/platform-scope-stores.js.map +1 -1
  40. package/dist/r2-backups.d.ts.map +1 -1
  41. package/dist/r2-backups.js +173 -34
  42. package/dist/r2-backups.js.map +1 -1
  43. package/dist/store-entry.d.ts.map +1 -1
  44. package/dist/store-entry.generated.d.ts +2 -7
  45. package/dist/store-entry.generated.d.ts.map +1 -1
  46. package/dist/store-entry.generated.js +5 -10
  47. package/dist/store-entry.generated.js.map +1 -1
  48. package/dist/store-entry.js +3 -2
  49. package/dist/store-entry.js.map +1 -1
  50. package/dist/store-move-ends.d.ts +59 -0
  51. package/dist/store-move-ends.d.ts.map +1 -0
  52. package/dist/store-move-ends.js +114 -0
  53. package/dist/store-move-ends.js.map +1 -0
  54. package/dist/store-move.d.ts +65 -0
  55. package/dist/store-move.d.ts.map +1 -0
  56. package/dist/store-move.js +107 -0
  57. package/dist/store-move.js.map +1 -0
  58. package/dist/store-releases.d.ts +18 -4
  59. package/dist/store-releases.d.ts.map +1 -1
  60. package/dist/store-releases.js +74 -11
  61. package/dist/store-releases.js.map +1 -1
  62. package/dist/store-script.d.ts +12 -2
  63. package/dist/store-script.d.ts.map +1 -1
  64. package/dist/store-script.js +47 -9
  65. package/dist/store-script.js.map +1 -1
  66. package/dist/tenant-request-query.d.ts.map +1 -1
  67. package/dist/tenant-request-query.js +5 -0
  68. package/dist/tenant-request-query.js.map +1 -1
  69. package/package.json +9 -9
package/dist/api.js CHANGED
@@ -14,7 +14,7 @@ import { TENANT_HEADER, confinedTenant } from './auth.js';
14
14
  import { mintTenantToken } from './tenant-token.js';
15
15
  import { abandonCopy, beginTransfer, connectionGrantsForScope, transferScope, isScopeNotHeld, notHeldIsBenign } from './vertical-client.js';
16
16
  import { COPY_JOB_MAX_MS, copyJobAt, copyJobHeld, copyJobId, copyJobLive, copyTagId, versionIdOfOrder, versionOrderOf, } from './copy-job.js';
17
- import { runningVersionOf, runningVersionStoresOf, scopeStoreReachOf, scopeStoresOf, unrouteScope } from './platform-scope-stores.js';
17
+ import { exportStoreDumps, runningVersionOf, runningVersionStoresOf, scopeStoreReachOf, scopeStoresOf, unrouteScope } from './platform-scope-stores.js';
18
18
  import { liveApprovalOf } from './store-script.js';
19
19
  import { STORE_REBUILD_REQUEST_SUBREQUESTS, runStoreFleetRebuild, storeRebuildBudget, storeRebuildStatus } from './store-rebuild.js';
20
20
  import { PUSH_HOLD_TTL_MS, STORE_RELEASE_STORED_MAX_BYTES, approveStoredDriverRelease, readStoreDriverApprovals, runStoreDriverRevoke, runStoreDriverRollout, storeDriverRelease, storedReleaseLoader } from './store-releases.js';
@@ -68,6 +68,8 @@ import { mintPushToken, pushActorFor } from './push-token.js';
68
68
  import { TENANT_SERIES_SCOPE_CAP, REQUEST_FACET_KEYS, LOGS_INCOMPLETE_HEADER } from './observability.js';
69
69
  import { parseTenantRequestQuery, parseTenantWindowQuery, refineTenantWindow, refuseSearchOnCounts, parseTenantLineQuery, REQUEST_WHERE_VALUES_MAX, scriptFamiliesOfScopes, } from './tenant-request-query.js';
70
70
  import { namespacesForScript } from './do-namespaces.js';
71
+ import { discardMoves, moveStores } from './store-move.js';
72
+ import { backupEnds, commitStages, inlineStoreSource, restoreEnds, snapshotEnds, StoreLandFailure, storeHoldsRows, } from './store-move-ends.js';
71
73
  import { backupDirectoryIfDue } from './directory-backup.js';
72
74
  import { deployedDoClasses, platformSweeperDecision } from './platform-entry.js';
73
75
  import { extractedMigrationsDigest, extractedMigrationsProblem, extractedMigrationsRefusal, MigrationExtractionError, MigrationLoaderUnavailable, } from './migration-extract.js';
@@ -75,7 +77,7 @@ import { wireSettingsRefusal, wireUploadFor } from './wire-upload.js';
75
77
  import { parseWireWalkCursor, runWireWalk, WIRE_WALK_LIMIT_DEFAULT, WIRE_WALK_LIMIT_MAX, WIRE_WALK_RELEASE_MAX, } from './wire-walk.js';
76
78
  import { wireKeyDropSince, wireReadinessReport, WIRE_READINESS_LIMIT_DEFAULT, WIRE_READINESS_LIMIT_MAX } from './wire-readiness.js';
77
79
  import { STORAGE_PAGE_DEFAULT, STORAGE_PAGE_MAX, readStoragePage } from './storage-meter.js';
78
- import { queryLakeEvents } from './lake-gateway.js';
80
+ import { queryLakeEvents, teamWindowRefusal } from './lake-gateway.js';
79
81
  import { LakeQueryError } from './r2-sql-engine.js';
80
82
  import { isCustomHostname, validateBindableHostname, } from './custom-hostnames.js';
81
83
  import { isSocialRelayVertical, reconcileScopeSocialRelay, relayHostnames, } from './social-relay.js';
@@ -780,6 +782,8 @@ const TENANT_ROUTES = [
780
782
  // grant (`tenantGrantableEntitlement`): the pin alone would let it grant itself any.
781
783
  { method: 'PUT', re: /\/tenants\/[^/]+\/entitlements\/[^/]+$/, pin: 'path' },
782
784
  { method: 'GET', re: /\/tenants\/[^/]+\/cross-vertical\/edges$/, pin: 'path' },
785
+ // #1334: the team's own lake events. The handler adds the window rule for this credential.
786
+ { method: 'GET', re: /\/tenants\/[^/]+\/lake\/events$/, pin: 'path' },
783
787
  // Findings (#1748): the tenant's inbox, its verdicts and its suppress rules. The dashboard
784
788
  // checks WHO inside the tenant may read or act (its findings permissions) before calling.
785
789
  { method: 'PUT', re: /\/tenants\/[^/]+\/findings\/[^/]+\/status$/, pin: 'path' },
@@ -1014,7 +1018,7 @@ export function createControlPlaneApi(options) {
1014
1018
  /** `substrat.wipe` on each declared store, before the scope's own bytes go. */
1015
1019
  const wipePlatformStores = async (c, scope) => (await platformStoresWipe(c, scope))();
1016
1020
  /**
1017
- * `substrat.export` on each store the lifecycle reaches, for a backup, a full pull or a fork.
1021
+ * The stores a backup, a full pull or a fork copies: those the scope's running version declares.
1018
1022
  *
1019
1023
  * A dump is only worth taking if it restores, and a restore imports only what the destination's
1020
1024
  * version declares (L8). So a binding only an earlier version declared (#2286) is left out of the
@@ -1023,24 +1027,29 @@ export function createControlPlaneApi(options) {
1023
1027
  * way out, rather than produce a dump its own scope could not restore. Wipe, reap and erasure
1024
1028
  * never export, so they reach such a store regardless.
1025
1029
  */
1026
- const exportPlatformStores = async (c, scope) => {
1030
+ const platformStoreReach = async (c, scope) => {
1027
1031
  const caller = options.platformScopeStores;
1028
1032
  if (!caller || !scope.vertical)
1029
1033
  return undefined;
1030
1034
  const { running, all } = await scopeStoreReachOf(c.var.admin, c.get('actor'), scope, options.storeDrivers);
1031
1035
  if (all.length === 0)
1032
1036
  return undefined;
1033
- const exported = await caller.exportStores({ tenantId: scope.tenantId, scopeId: scope.id, vertical: scope.vertical, stores: all });
1037
+ const place = { tenantId: scope.tenantId, scopeId: scope.id, vertical: scope.vertical };
1034
1038
  const current = new Set(running.map((store) => store.binding));
1035
- const dropped = exported.filter((store) => !current.has(store.binding));
1036
- const held = dropped.find((store) => store.tables.some((table) => table.rows.length > 0));
1037
- if (held) {
1038
- throw new ControlPlaneError(409, `scope ${scope.id}'s store '${held.binding}' was declared by an earlier version of '${scope.vertical}' and ` +
1039
+ for (const dropped of all.filter((store) => !current.has(store.binding))) {
1040
+ if (!(await storeHoldsRows(caller, place, dropped)))
1041
+ continue;
1042
+ throw new ControlPlaneError(409, `scope ${scope.id}'s store '${dropped.binding}' was declared by an earlier version of '${scope.vertical}' and ` +
1039
1043
  `still holds data, and the version the scope runs does not declare it, so a dump of it could not be ` +
1040
- `restored. Declare '${held.binding}' as a '${held.driver}' again in a version the scope runs, then export; ` +
1044
+ `restored. Declare '${dropped.binding}' as a '${dropped.driver}' again in a version the scope runs, then export; ` +
1041
1045
  `or reap the scope without a backup, which wipes it`);
1042
1046
  }
1043
- return exported.filter((store) => current.has(store.binding));
1047
+ return { caller, place, stores: running };
1048
+ };
1049
+ /** The stores a pull or a tenant export answers inline, each read whole (`exportStoreDumps`). */
1050
+ const exportPlatformStores = async (c, scope) => {
1051
+ const reach = await platformStoreReach(c, scope);
1052
+ return reach && exportStoreDumps(reach.caller, reach.place, reach.stores);
1044
1053
  };
1045
1054
  /**
1046
1055
  * `substrat.erase` on the destination's stores for each subject tombstoned in the destination
@@ -1262,13 +1271,23 @@ export function createControlPlaneApi(options) {
1262
1271
  // #977: a tenant credential minted for a person writes through a view that names
1263
1272
  // them on every admin row. Only the credential's own claim can supply the person —
1264
1273
  // never a header or a body — and only the staff mint writes that claim.
1265
- const view = principal.kind === 'tenant' && principal.onBehalfOf && host.attributed
1266
- ? host.attributed(principal.onBehalfOf)
1267
- : host;
1274
+ const view = viewOf(principal);
1268
1275
  c.set('host', view);
1269
1276
  c.set('admin', view.admin);
1270
1277
  await next();
1271
1278
  });
1279
+ /**
1280
+ * #977: the host a principal's request writes through — the attributed view for a tenant
1281
+ * credential minted for a person, the configured host otherwise. The middleware sets it on
1282
+ * the request; a helper handed only the principal derives the same view here (#2860), so its
1283
+ * access rows name the person too.
1284
+ */
1285
+ function viewOf(principal) {
1286
+ return principal.kind === 'tenant' && principal.onBehalfOf && host.attributed
1287
+ ? host.attributed(principal.onBehalfOf)
1288
+ : host;
1289
+ }
1290
+ const adminFor = (principal) => viewOf(principal).admin;
1272
1291
  // Confine a BUILDER to the vertical-management surface, fail-CLOSED (builder-plane.md
1273
1292
  // §4). Default-deny by design: a builder reaches only the routes on this allowlist,
1274
1293
  // and only for verticals its tenant owns (the per-route ownership checks below). A
@@ -1299,6 +1318,9 @@ export function createControlPlaneApi(options) {
1299
1318
  { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/model$/ },
1300
1319
  { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/concept$/ },
1301
1320
  { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/schedules$/ },
1321
+ // The scope-store drivers one vertical may use (#2813), so a push judges its stores as the
1322
+ // upload will. `?vertical=` required; the answer names no other vertical.
1323
+ { method: 'GET', re: /\/store-drivers$/ },
1302
1324
  { method: 'GET', re: /\/verticals\/[^/]+\/channels$/ },
1303
1325
  { method: 'GET', re: /\/verticals\/[^/]+\/channels\/[^/]+\/history$/ },
1304
1326
  { method: 'POST', re: /\/verticals\/[^/]+\/channels\/[^/]+\/promote$/ },
@@ -1542,7 +1564,7 @@ export function createControlPlaneApi(options) {
1542
1564
  const vertical = await resolveVerticalId(c, raw);
1543
1565
  const owner = (pathTenant ?? confined)
1544
1566
  ? null
1545
- : ((await verticalOf(c.get('actor'), vertical))?.ownerTenant ?? null);
1567
+ : ((await verticalOf(c.var.admin, c.get('actor'), vertical))?.ownerTenant ?? null);
1546
1568
  return { vertical, tenantId: pathTenant ?? confined ?? owner };
1547
1569
  }
1548
1570
  catch {
@@ -2944,7 +2966,7 @@ export function createControlPlaneApi(options) {
2944
2966
  resolveVertical: options.resolveVertical,
2945
2967
  resolveVerticalVersion: options.resolveVerticalVersion,
2946
2968
  resolveVerticalRef: options.resolveVerticalRef,
2947
- ownerOf,
2969
+ ownerOf: (actor, slug) => ownerOf(c.var.admin, actor, slug),
2948
2970
  tenantSlugOf: async (actor, tenantId) => (await c.var.admin.getTenant(actor, tenantId).catch(() => null))?.slug ?? null,
2949
2971
  }, c.get('actor'), scope);
2950
2972
  /**
@@ -2959,11 +2981,11 @@ export function createControlPlaneApi(options) {
2959
2981
  * exact split so it is a seconds-long diagnosis, not a multi-hour hunt. A couple of
2960
2982
  * directory reads only when a call has already failed to resolve.
2961
2983
  */
2962
- const diagnoseUnboundScope = async (actor, scope) => {
2984
+ const diagnoseUnboundScope = async (admin, actor, scope) => {
2963
2985
  const slug = scope.vertical;
2964
2986
  if (!slug)
2965
2987
  return 'scope has no vertical bound — nothing to deliver to';
2966
- const versions = await host.admin.listVersions(actor, slug).catch(() => []);
2988
+ const versions = await admin.listVersions(actor, slug).catch(() => []);
2967
2989
  const boundId = scope.verticalVersionId;
2968
2990
  if (boundId && !versions.some((v) => v.id === boundId)) {
2969
2991
  return (`scope is bound to version '${boundId}', which is not among vertical '${slug}'’s ${versions.length} ` +
@@ -2976,7 +2998,7 @@ export function createControlPlaneApi(options) {
2976
2998
  `pushes are on different slugs (a lineage fork). Push under '${slug}' by setting \`substrat.slug\`, ` +
2977
2999
  `or rebind the install to the slug the versions live under.`);
2978
3000
  }
2979
- const serving = await host.admin.verticalServing(actor, slug).catch(() => null);
3001
+ const serving = await admin.verticalServing(actor, slug).catch(() => null);
2980
3002
  if (!serving) {
2981
3003
  return `vertical '${slug}' has ${versions.length} version(s) but none is promoted to a serving channel — promote one to prod.`;
2982
3004
  }
@@ -2990,10 +3012,10 @@ export function createControlPlaneApi(options) {
2990
3012
  * `servingRef` is the route the scope takes in the same act (an adopt). The host's read also
2991
3013
  * refuses, whatever is acknowledged, a move that strands people a link bound (#2178).
2992
3014
  */
2993
- const refuseOnBreaks = async (actor, tenantId, scopeId, versionId, opts) => {
3015
+ const refuseOnBreaks = async (admin, actor, tenantId, scopeId, versionId, opts) => {
2994
3016
  // Asked even when acknowledged: the read also refuses a move that strands people a link
2995
3017
  // bound (#2178), which no acknowledgement passes.
2996
- const breaks = await host.admin.bindingImpact(actor, tenantId, scopeId, versionId, opts.servingRef !== undefined ? { servingRef: opts.servingRef } : undefined);
3018
+ const breaks = await admin.bindingImpact(actor, tenantId, scopeId, versionId, opts.servingRef !== undefined ? { servingRef: opts.servingRef } : undefined);
2997
3019
  if (opts.acknowledge?.exportBreak)
2998
3020
  return;
2999
3021
  if (breaks.length > 0)
@@ -3025,7 +3047,7 @@ export function createControlPlaneApi(options) {
3025
3047
  * as it stood before the failed attempt. Every caller still re-asserts after it (the
3026
3048
  * fallback), passing what this reports.
3027
3049
  */
3028
- const restoreCarryingSwitches = async (actor, dest, tenantId, scopeId, tables,
3050
+ const restoreCarryingSwitches = async (admin, actor, dest, tenantId, scopeId, tables,
3029
3051
  /** #1869: the scope `tables` were captured from — `scopeId` itself unless the copy moves —
3030
3052
  * and `exact` when the platform exported them itself rather than a caller supplying them.
3031
3053
  * #1722: `loadStamp`, the stamp a carry leaves on the copy it lands, and `expect`, the marker
@@ -3035,10 +3057,10 @@ export function createControlPlaneApi(options) {
3035
3057
  // the destination is not primary, the vertical marks it a copy in its own storage — a carry
3036
3058
  // of a copy made before every copy carried the marker brings none, and a CP-less host reads
3037
3059
  // the marker for primacy. Idempotent there; an install is never marked.
3038
- const rec = await host.admin.getScopeRecord(actor, tenantId, scopeId);
3060
+ const rec = await admin.getScopeRecord(actor, tenantId, scopeId);
3039
3061
  const markCopy = rec ? copyLineageOf(rec) : undefined;
3040
3062
  return retryTransient(async () => dest.restoreScope(tenantId, scopeId, tables, {
3041
- ...(await switchCarryFor(host.admin, actor, { tenantId, scopeId })),
3063
+ ...(await switchCarryFor(admin, actor, { tenantId, scopeId })),
3042
3064
  sourceScopeId: source.scopeId,
3043
3065
  exact: source.exact,
3044
3066
  loadStamp: source.loadStamp,
@@ -3072,7 +3094,7 @@ export function createControlPlaneApi(options) {
3072
3094
  }
3073
3095
  // #1756: onto the serving script, the scope runs the served version from the moment routing
3074
3096
  // flips — whatever its own version exported. Asked before the export below moves anything.
3075
- await refuseOnBreaks(actor, tenantId, scopeId, serving.versionId, {
3097
+ await refuseOnBreaks(c.var.admin, actor, tenantId, scopeId, serving.versionId, {
3076
3098
  servingRef: serving.ref,
3077
3099
  acknowledge: opts.acknowledge,
3078
3100
  });
@@ -3115,7 +3137,7 @@ export function createControlPlaneApi(options) {
3115
3137
  const serving = await c.var.admin.verticalServing(actor, slug);
3116
3138
  if (!serving)
3117
3139
  return none; // embedded / not dispatch-backed — the host cascade handled rebinds
3118
- const v = await verticalOf(actor, slug);
3140
+ const v = await verticalOf(c.var.admin, actor, slug);
3119
3141
  if (!v || v.ownerTenant === null || v.listed)
3120
3142
  return none; // private only, like the host cascade
3121
3143
  const owned = (await c.var.admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug, status: ['active'] })).filter((s) => !s.forkedFrom && s.kind !== 'preview');
@@ -3213,7 +3235,7 @@ export function createControlPlaneApi(options) {
3213
3235
  const { review, ...bindAck } = opts.acknowledge ?? {};
3214
3236
  // #1756: within one lineage this is an adopt, and judged as one before anything moves. A
3215
3237
  // crossing is not judged (`bindExportBreaksOf`): the producer's slug itself changes.
3216
- await refuseOnBreaks(actor, tenantId, scopeId, serving.versionId, {
3238
+ await refuseOnBreaks(c.var.admin, actor, tenantId, scopeId, serving.versionId, {
3217
3239
  servingRef: serving.ref,
3218
3240
  acknowledge: bindAck,
3219
3241
  });
@@ -3319,7 +3341,7 @@ export function createControlPlaneApi(options) {
3319
3341
  const erasureEpoch = await c.var.admin.scopeErasureEpoch(actor, tenantId, scopeId);
3320
3342
  // #2450: a page at a time into the destination's stage, so a scope of any size moves.
3321
3343
  const { dump, loadStamp: sourceStamp, revision: sourceRevision } = await transferScope(source, dest, { from: scopeId }, { retry: retryTransient });
3322
- const restored = await restoreCarryingSwitches(actor, dest, tenantId, scopeId, dump, {
3344
+ const restored = await restoreCarryingSwitches(c.var.admin, actor, dest, tenantId, scopeId, dump, {
3323
3345
  scopeId, exact: true, loadStamp: restoredStamp,
3324
3346
  ...(moveId ? { fence: copyRestoreFence(moveId, recordedAt, copyLeaseMs, erasureEpoch) } : {}),
3325
3347
  });
@@ -3744,7 +3766,7 @@ export function createControlPlaneApi(options) {
3744
3766
  throw new ControlPlaneError(412, `the copy of scope ${scope.id} in '${from}' was already carried to another script and wiped, so nothing ` +
3745
3767
  `was moved or bound (#1722). Reload the scope and retry.`);
3746
3768
  }
3747
- const restored = await restoreCarryingSwitches(actor, dest, scope.tenantId, scope.id, dump, {
3769
+ const restored = await restoreCarryingSwitches(c.var.admin, actor, dest, scope.tenantId, scope.id, dump, {
3748
3770
  scopeId: scope.id,
3749
3771
  exact: true,
3750
3772
  loadStamp: restoredStamp,
@@ -4111,7 +4133,7 @@ export function createControlPlaneApi(options) {
4111
4133
  await c.var.admin.recordScopeScriptCopy(actor, scope.tenantId, scope.id, carried.to, recarryId, { role: 'destination', loadStamp: restoredStamp, leaseMs: copyLeaseMs });
4112
4134
  try {
4113
4135
  const erasureEpoch = await c.var.admin.scopeErasureEpoch(actor, scope.tenantId, scope.id);
4114
- await restoreCarryingSwitches(actor, carried.dest, scope.tenantId, scope.id, dump, {
4136
+ await restoreCarryingSwitches(c.var.admin, actor, carried.dest, scope.tenantId, scope.id, dump, {
4115
4137
  scopeId: scope.id,
4116
4138
  exact: true,
4117
4139
  loadStamp: restoredStamp,
@@ -4277,13 +4299,13 @@ export function createControlPlaneApi(options) {
4277
4299
  const p = c.get('principal');
4278
4300
  const scopePin = confinedTenant(p);
4279
4301
  if (scopePin) {
4280
- const v = scope.vertical ? await verticalOf(actor, scope.vertical) : undefined;
4302
+ const v = scope.vertical ? await verticalOf(c.var.admin, actor, scope.vertical) : undefined;
4281
4303
  if (!v || v.ownerTenant !== scopePin)
4282
4304
  return refuse(c, 'forbidden', 'forbidden');
4283
4305
  }
4284
4306
  const reached = await deploymentForScope(c, scope);
4285
4307
  if (!reached)
4286
- return refuseStatus(c, 501, await diagnoseUnboundScope(c.get('actor'), scope));
4308
+ return refuseStatus(c, 501, await diagnoseUnboundScope(c.var.admin, c.get('actor'), scope));
4287
4309
  const vertical = reached.client;
4288
4310
  const entitlements = await c.var.admin.listEntitlements(actor, tenantId);
4289
4311
  // #406: re-gathered and re-delivered like entitlements, so a reconcile also repairs a
@@ -4826,7 +4848,7 @@ export function createControlPlaneApi(options) {
4826
4848
  return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4827
4849
  const vertical = await verticalForScope(c, scope);
4828
4850
  if (!vertical)
4829
- return refuseStatus(c, 501, await diagnoseUnboundScope(c.get('actor'), scope));
4851
+ return refuseStatus(c, 501, await diagnoseUnboundScope(c.var.admin, c.get('actor'), scope));
4830
4852
  try {
4831
4853
  return c.json(await vertical.ownerSeat(tenantId, scopeId));
4832
4854
  }
@@ -4862,7 +4884,7 @@ export function createControlPlaneApi(options) {
4862
4884
  return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4863
4885
  const vertical = await verticalForScope(c, scope);
4864
4886
  if (!vertical)
4865
- return refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope));
4887
+ return refuseStatus(c, 501, await diagnoseUnboundScope(c.var.admin, actor, scope));
4866
4888
  const origin = await instanceOrigin(c.var.admin, actor, scopeId);
4867
4889
  if (!origin) {
4868
4890
  return refuse(c, 'conflict', `scope ${scopeId} has no hostname bound — a claim link needs an address to open on`);
@@ -4903,7 +4925,7 @@ export function createControlPlaneApi(options) {
4903
4925
  return { refusal: refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`) };
4904
4926
  const vertical = await verticalForScope(c, scope);
4905
4927
  if (!vertical)
4906
- return { refusal: refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope)) };
4928
+ return { refusal: refuseStatus(c, 501, await diagnoseUnboundScope(c.var.admin, actor, scope)) };
4907
4929
  return { tenantId, scopeId, actor, vertical, person: memberPerson(principal) };
4908
4930
  };
4909
4931
  /**
@@ -5023,7 +5045,7 @@ export function createControlPlaneApi(options) {
5023
5045
  return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5024
5046
  const vertical = await verticalForScope(c, scope);
5025
5047
  if (!vertical)
5026
- return refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope));
5048
+ return refuseStatus(c, 501, await diagnoseUnboundScope(c.var.admin, actor, scope));
5027
5049
  const operationId = ulid();
5028
5050
  // An abandon (`abandon: true`) is audited the same way, every row marked, so the log tells
5029
5051
  // a hand-over closed without finishing from one that finished.
@@ -5059,7 +5081,7 @@ export function createControlPlaneApi(options) {
5059
5081
  return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5060
5082
  const vertical = await verticalForScope(c, scope);
5061
5083
  if (!vertical) {
5062
- return refuseStatus(c, 501, await diagnoseUnboundScope(c.get('actor'), scope));
5084
+ return refuseStatus(c, 501, await diagnoseUnboundScope(c.var.admin, c.get('actor'), scope));
5063
5085
  }
5064
5086
  try {
5065
5087
  await vertical.configureInstance({ tenantId, scopeId, entries: input.entries });
@@ -5212,12 +5234,17 @@ export function createControlPlaneApi(options) {
5212
5234
  }
5213
5235
  await retryTransient(() => copier.snapshotScope({ sourceScopeId: scope.id, newScopeId: snapId, tenantId }));
5214
5236
  // The scope file was copied inside the script. The store objects are per scope, so the
5215
- // fork gets its own copy through the reserved export and import, signed by the
5216
- // control plane. A version that declares no stores has nothing to copy.
5237
+ // fork gets its own copy, page by page from the source's store into a stage at the fork's,
5238
+ // signed by the control plane (D-69); every store is staged before any is committed. A
5239
+ // version that declares no stores has nothing to copy.
5217
5240
  if (reach) {
5218
- const stores = await reach.caller.exportStores({ tenantId, scopeId: scope.id, vertical: reach.vertical, stores: reach.stores });
5219
- if (stores.length > 0) {
5220
- await reach.caller.importStores({ tenantId, scopeId: snapId, vertical: reach.vertical, stores });
5241
+ const from = { tenantId, scopeId: scope.id, vertical: reach.vertical };
5242
+ const to = { ...from, scopeId: snapId };
5243
+ const moved = await moveStores(snapshotEnds(reach.caller, from, to), reach.stores, `scope ${scope.id}'s stores`, options.storeMoveBudget);
5244
+ try {
5245
+ await commitStages(reach.caller, to, moved, retryTransient);
5246
+ }
5247
+ finally {
5221
5248
  replay = await replayPlatformStoreErasures(c, { ...scope, id: snapId }, scope.id);
5222
5249
  }
5223
5250
  }
@@ -5399,9 +5426,11 @@ export function createControlPlaneApi(options) {
5399
5426
  const { tables: hostTables, ...header } = await c.var.admin.exportScope(c.get('actor'), tenantId, scope.id);
5400
5427
  const vertical = await verticalForScope(c, scope);
5401
5428
  // Store tables are opaque and stay unsealed. The platform reads them from the store script
5402
- // (#2207); a store it cannot name or reach refuses the backup rather than omitting it.
5403
- const stores = (await exportPlatformStores(c, scope)) ?? header.stores;
5404
- const backup = backupStage(store.begin({ vertical: scope.vertical, header: { ...header, stores } }), sealerFor(c, tenantId, scope.id));
5429
+ // (#2207), a page at a time into entries of their own (D-69); a store it cannot name or reach
5430
+ // refuses the backup rather than omitting it. Resolved before anything is written.
5431
+ const reach = await platformStoreReach(c, scope);
5432
+ const writer = store.begin({ vertical: scope.vertical, header: { ...header, stores: reach ? [] : header.stores } });
5433
+ const backup = backupStage(writer, sealerFor(c, tenantId, scope.id));
5405
5434
  // #2839: from a vertical, the data moves into the store a page at a time, as a paged copy
5406
5435
  // moves it into another scope (#2450): captured, so a scope taking writes meanwhile is still
5407
5436
  // copied as it was at one moment, and never held whole here. A deployment that predates
@@ -5411,11 +5440,18 @@ export function createControlPlaneApi(options) {
5411
5440
  : hostTables;
5412
5441
  if (Array.isArray(moved))
5413
5442
  await backup.dest.stageScopePage(scope.id, ulid(), 0, moved);
5443
+ // The scope, then its stores: two objects, so not one moment (D-69, question 3).
5444
+ let stores = [];
5414
5445
  try {
5415
- return await backup.commit();
5446
+ if (reach) {
5447
+ stores = await moveStores(backupEnds(reach.caller, reach.place, writer), reach.stores, `scope ${scope.id}'s stores`, options.storeMoveBudget);
5448
+ }
5449
+ return await backup.commit(stores.map((s) => ({ binding: s.binding, driver: s.driver, stageId: s.moveId, entries: s.pages, revision: s.revision })));
5416
5450
  }
5417
5451
  catch (e) {
5418
5452
  await backup.discard();
5453
+ for (const s of stores)
5454
+ await writer.discard(s.moveId).catch(() => undefined);
5419
5455
  throw e;
5420
5456
  }
5421
5457
  };
@@ -5454,7 +5490,7 @@ export function createControlPlaneApi(options) {
5454
5490
  },
5455
5491
  discardStagedScope: (_, stageId) => writer.discard(stageId),
5456
5492
  },
5457
- commit: () => writer.commit({ stageId: last.stageId, entries: last.entries, tables: last.tables.size }),
5493
+ commit: (storeEntries) => writer.commit({ stageId: last.stageId, entries: last.entries, tables: last.tables.size, storeEntries }),
5458
5494
  discard: () => writer.discard(last.stageId),
5459
5495
  };
5460
5496
  };
@@ -5494,6 +5530,16 @@ export function createControlPlaneApi(options) {
5494
5530
  if (held.entries > 1) {
5495
5531
  return refuseStatus(c, 413, `backup ${capturedAt} of scope ${scopeId} is stored in ${held.entries} entries (its pages, and any writes caught while it was taken), which only a restore applies; restore it with POST /tenants/${tenantId}/scopes/${scopeId}/backups/${capturedAt}/restore`);
5496
5532
  }
5533
+ // A store held in one page is served inline, as `stores` always was; one held in more is a
5534
+ // restore's to apply (D-69), so the backup is refused whole rather than handed back in part.
5535
+ const paged = held.storeEntries.find((store) => store.entries > 1);
5536
+ if (paged) {
5537
+ return refuseStatus(c, 413, `backup ${capturedAt} of scope ${scopeId} holds scope store '${paged.binding}' in ${paged.entries} pages, which only a restore applies; restore it with POST /tenants/${tenantId}/scopes/${scopeId}/backups/${capturedAt}/restore`);
5538
+ }
5539
+ const pagedStores = [];
5540
+ for (const store of held.storeEntries) {
5541
+ pagedStores.push({ binding: store.binding, driver: store.driver, tables: mergeDumpPages([await held.storePage(store, 0)]) });
5542
+ }
5497
5543
  const { tables } = await held.entry(0);
5498
5544
  // Unseal on the way out (#37). The bytes in the store stay sealed — this is the
5499
5545
  // authorized read opening them with the keys the platform holds. A subject shredded
@@ -5501,7 +5547,7 @@ export function createControlPlaneApi(options) {
5501
5547
  // working: the ciphertext never left the object, and nothing turns it back into a
5502
5548
  // person. A dump taken before sealing existed passes through untouched.
5503
5549
  const opened = await openDump(mergeDumpPages([tables]), sealerFor(c, tenantId, scopeId));
5504
- return c.json({ ...held.header, tables: opened });
5550
+ return c.json({ ...held.header, stores: [...held.header.stores, ...pagedStores], tables: opened });
5505
5551
  });
5506
5552
  // Take a backup WITHOUT reaping — the standalone copy (a pre-migration checkpoint, an
5507
5553
  // export-to-keep). The reap route below takes its own; this is the same act made
@@ -5912,7 +5958,7 @@ export function createControlPlaneApi(options) {
5912
5958
  }
5913
5959
  const vertical = await verticalForScope(c, scope);
5914
5960
  if (!vertical)
5915
- return refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope));
5961
+ return refuseStatus(c, 501, await diagnoseUnboundScope(c.var.admin, actor, scope));
5916
5962
  const { cleared } = await vertical.clearCopyMark(scopeId, { kind: scope.kind, forkedFrom: scope.forkedFrom });
5917
5963
  await c.var.admin.recordCopyMark(actor, { action: 'clear', tenantId, scopeId, outcome: cleared ? 'cleared' : 'absent' });
5918
5964
  return c.json({ cleared });
@@ -6012,9 +6058,10 @@ export function createControlPlaneApi(options) {
6012
6058
  // closed `.strict()` list with no tenant in it, and a row of any other tenant fails the
6013
6059
  // read whole. Envelope only — no payload until erasure reaches the lake.
6014
6060
  //
6015
- // Staff only, by default-deny: on neither BUILDER_ROUTES nor TENANT_ROUTES. A tenant's
6016
- // own credential reaching this is a separate decision, with a dashboard call site and a
6017
- // member permission in front of it.
6061
+ // Staff, and a team's own credential (TENANT_ROUTES, path-pinned). The dashboard checks
6062
+ // `dashboard:read-event-lake` before it calls. A team's read must name a window of at most
6063
+ // `LAKE_TEAM_WINDOW_DAYS` (`teamWindowRefusal`): nothing below the gateway prunes by tenant,
6064
+ // so the window is what bounds the scan. Builders stay refused (not in BUILDER_ROUTES).
6018
6065
  //
6019
6066
  // K-24: the access row is written AFTER the engine answers and BEFORE anything is
6020
6067
  // returned, and a failed write fails the request — rows whose disclosure went unrecorded
@@ -6035,6 +6082,11 @@ export function createControlPlaneApi(options) {
6035
6082
  issues: parsed.error.issues.map((i) => ({ path: i.path, message: i.message })),
6036
6083
  });
6037
6084
  }
6085
+ if (c.get('principal').kind === 'tenant') {
6086
+ const refusal = teamWindowRefusal(parsed.data, Date.now());
6087
+ if (refusal)
6088
+ return refuse(c, 'validation_failed', refusal);
6089
+ }
6038
6090
  let page;
6039
6091
  try {
6040
6092
  page = await queryLakeEvents(options.lakeQuery, tenantId, parsed.data);
@@ -6370,9 +6422,15 @@ export function createControlPlaneApi(options) {
6370
6422
  if (!held)
6371
6423
  return refuse(c, 'not_found', `no backup for scope ${scopeId} at ${capturedAt}`);
6372
6424
  const { header } = held;
6425
+ // A backup taken since D-69 holds its stores as store pages; one taken before, inline in its header.
6426
+ const pagedStores = held.storeEntries.map((entry) => ({
6427
+ binding: entry.binding,
6428
+ driver: entry.driver,
6429
+ source: { pages: entry.entries, revision: entry.revision, page: (seq) => held.storePage(entry, seq) },
6430
+ }));
6373
6431
  if (options.platformScopeStores) {
6374
6432
  try {
6375
- await refuseUndeclaredDumpStores(c, scope, header.stores);
6433
+ await refuseUndeclaredDumpStores(c, scope, [...header.stores, ...pagedStores]);
6376
6434
  }
6377
6435
  catch (e) {
6378
6436
  if (e instanceof ControlPlaneError)
@@ -6387,7 +6445,7 @@ export function createControlPlaneApi(options) {
6387
6445
  if (tables.length === 0) {
6388
6446
  return refuseStatus(c, 422, 'restore refused: the backup has no tables — not a scope dump');
6389
6447
  }
6390
- return c.json(await loadIntoScope(c, scope, { header, tables: await openDump(mergeDumpPages([tables]), sealer) }));
6448
+ return c.json(await loadIntoScope(c, scope, { header, tables: await openDump(mergeDumpPages([tables]), sealer), pagedStores }));
6391
6449
  }
6392
6450
  // The host judges the roles the backup seats or defines before anything is replaced (#2555), and
6393
6451
  // records the tables it restores. So it is handed every table's schema and the role tables' rows,
@@ -6421,7 +6479,7 @@ export function createControlPlaneApi(options) {
6421
6479
  }
6422
6480
  return { dump: { stageId }, tables: judged.tables() };
6423
6481
  };
6424
- return c.json(await loadIntoScope(c, scope, { header, tables: [], stage }));
6482
+ return c.json(await loadIntoScope(c, scope, { header, tables: [], stage, pagedStores }));
6425
6483
  }
6426
6484
  catch (e) {
6427
6485
  return restoreFailed(c, scope, e);
@@ -6450,32 +6508,55 @@ export function createControlPlaneApi(options) {
6450
6508
  // #2185: cleared before the load as well as after it (below): a step after the load can still
6451
6509
  // throw (the deployment's half, the re-assert), and the data that landed may predate a reconcile.
6452
6510
  await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null);
6453
- const staged = input.stage && vertical ? await input.stage(vertical) : undefined;
6454
- const tables = staged?.tables ?? input.tables;
6511
+ // D-69: every store is staged first, then the scope is loaded, then the stores are committed,
6512
+ // then erasures are replayed. Until the first commit nothing of a store has changed, so a page
6513
+ // that cannot be read, a refused stage or a refused load leaves every store as it was. Once a
6514
+ // commit was attempted the replay runs whatever follows, and a restore run again converges.
6515
+ const storesCaller = options.platformScopeStores;
6516
+ const platform = storesCaller && vertical && scope.vertical
6517
+ ? { caller: storesCaller, place: { tenantId, scopeId, vertical: scope.vertical } }
6518
+ : undefined;
6519
+ const pagedStores = input.pagedStores ?? [];
6520
+ if (!platform && pagedStores.length > 0) {
6521
+ throw new ControlPlaneError(409, `the backup holds scope stores in pages, and scope ${scopeId} has no deployment and no route to its stores to load them into`);
6522
+ }
6523
+ // With a route to the stores, an inline store moves as a paged one does; without one the host carries it.
6524
+ const stores = platform ? [...inlineStoreSources(header.stores), ...pagedStores] : [];
6525
+ const sources = new Map(stores.map((store) => [store.binding, store.source]));
6526
+ const storeEnds = platform && restoreEnds(platform.caller, platform.place, (binding) => sources.get(binding));
6527
+ const movedStores = storeEnds && stores.length > 0
6528
+ ? await moveStores(storeEnds, stores.map(({ binding, driver }) => ({ binding, driver })), `the backup's stores for scope ${scopeId}`, options.storeMoveBudget)
6529
+ : [];
6530
+ let staged;
6531
+ let committing = false;
6455
6532
  try {
6456
- const storesCaller = options.platformScopeStores;
6457
- const landing = { ...header, tables, stores: storesCaller && vertical ? [] : header.stores };
6533
+ staged = input.stage && vertical ? await input.stage(vertical) : undefined;
6534
+ const tables = staged?.tables ?? input.tables;
6535
+ const landing = { ...header, tables, stores: platform ? [] : header.stores };
6458
6536
  const hostReplay = await c.var.host.restoreScope(actor, tenantId, scopeId, landing, sourceHint ? { sourceScopeId: sourceHint } : undefined);
6459
- if (storesCaller && vertical && scope.vertical && header.stores.length > 0) {
6460
- await storesCaller.importStores({
6461
- tenantId,
6462
- scopeId,
6463
- vertical: scope.vertical,
6464
- stores: header.stores,
6465
- });
6466
- }
6467
- const platformReplay = storesCaller && scope.vertical
6468
- ? await replayPlatformStoreErasures(c, scope, sourceHint ?? origin)
6469
- : undefined;
6470
- const replays = [hostReplay, platformReplay].filter((r) => !!r);
6471
6537
  // #1742: the recorded OFF positions ride the restore, so the deployment switches them
6472
6538
  // off in the replay's own event — its own sweeper cannot land in between.
6473
6539
  const restored = vertical
6474
- ? await restoreCarryingSwitches(actor, vertical, tenantId, scopeId, staged?.dump ?? tables, {
6540
+ ? await restoreCarryingSwitches(c.var.admin, actor, vertical, tenantId, scopeId, staged?.dump ?? tables, {
6475
6541
  scopeId: sourceHint ?? origin,
6476
6542
  exact: false,
6477
6543
  })
6478
6544
  : undefined;
6545
+ let landFailure;
6546
+ if (platform && movedStores.length > 0) {
6547
+ committing = true;
6548
+ await commitStages(platform.caller, platform.place, movedStores, retryTransient).catch((e) => {
6549
+ if (!(e instanceof StoreLandFailure))
6550
+ throw e;
6551
+ landFailure = e;
6552
+ });
6553
+ }
6554
+ const platformReplay = storesCaller && scope.vertical
6555
+ ? await replayPlatformStoreErasures(c, scope, sourceHint ?? origin)
6556
+ : undefined;
6557
+ if (landFailure)
6558
+ throw landFailure;
6559
+ const replays = [hostReplay, platformReplay].filter((r) => !!r);
6479
6560
  // #1674: a backup from before a module was switched off brings it back on; the
6480
6561
  // directory's record puts it back off now, not at the next reconcile. Where the
6481
6562
  // deployment already did (#1742), this finds it done and audits what it reported.
@@ -6495,12 +6576,17 @@ export function createControlPlaneApi(options) {
6495
6576
  };
6496
6577
  }
6497
6578
  catch (e) {
6498
- // A stage the refused load left at the deployment goes now rather than waiting for a wipe.
6579
+ // A stage the refused load left at the deployment goes now rather than waiting for a wipe,
6580
+ // and so do the stores' stages, none of which was committed.
6499
6581
  if (staged && vertical)
6500
6582
  await abandonCopy({ dest: vertical }, { from: scopeId, into: scopeId, stageId: staged.dump.stageId });
6583
+ if (storeEnds && !committing)
6584
+ await discardMoves(storeEnds, movedStores);
6501
6585
  throw e;
6502
6586
  }
6503
6587
  };
6588
+ /** A dump's inline stores as restore sources, each cut at its driver's page size (D-69). */
6589
+ const inlineStoreSources = (stores) => stores.map((store) => ({ binding: store.binding, driver: store.driver, source: inlineStoreSource(store) }));
6504
6590
  /** How a refused or failed restore answers, on either route. */
6505
6591
  const restoreFailed = async (c, scope, e) => {
6506
6592
  // #2185: and once more on the way out of a failure, for a sweep reconcile that wrote the
@@ -6622,11 +6708,11 @@ export function createControlPlaneApi(options) {
6622
6708
  const actor = c.get('actor');
6623
6709
  const adoptPin = confinedTenant(p);
6624
6710
  if (adoptPin) {
6625
- const v = await verticalOf(actor, slug);
6711
+ const v = await verticalOf(c.var.admin, actor, slug);
6626
6712
  if (!v || v.ownerTenant !== adoptPin)
6627
6713
  return refuse(c, 'forbidden', 'forbidden');
6628
6714
  }
6629
- const v = await verticalOf(actor, slug);
6715
+ const v = await verticalOf(c.var.admin, actor, slug);
6630
6716
  if (!v)
6631
6717
  return refuse(c, 'not_found', `unknown vertical '${slug}'`);
6632
6718
  if (v.ownerTenant === null) {
@@ -6932,7 +7018,7 @@ export function createControlPlaneApi(options) {
6932
7018
  // #2185: before the load as well as after it, so a throw once the kept copy has landed (the
6933
7019
  // re-assert) cannot leave the receipt set over older data.
6934
7020
  await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null);
6935
- const restored = await restoreCarryingSwitches(actor, live, tenantId, scopeId, dump, {
7021
+ const restored = await restoreCarryingSwitches(c.var.admin, actor, live, tenantId, scopeId, dump, {
6936
7022
  scopeId,
6937
7023
  exact: true,
6938
7024
  loadStamp: ulid(),
@@ -6999,7 +7085,7 @@ export function createControlPlaneApi(options) {
6999
7085
  // first (a version not admitted) the same way. An unknown scope has no impact to read, and
7000
7086
  // the bind below refuses it as it always has.
7001
7087
  if (scope)
7002
- await refuseOnBreaks(actor, tenantId, scopeId, versionId, { acknowledge });
7088
+ await refuseOnBreaks(c.var.admin, actor, tenantId, scopeId, versionId, { acknowledge });
7003
7089
  // A refusal throws a ControlPlaneError, which the app's error boundary relays with its
7004
7090
  // status and records as an ops failure. An unknown scope carries nothing, and the bind
7005
7091
  // below refuses it as it always has.
@@ -7045,7 +7131,7 @@ export function createControlPlaneApi(options) {
7045
7131
  try {
7046
7132
  // Asked again after the carry: a promote landing since the first question would
7047
7133
  // otherwise leave an archive behind for a bind the host then refuses.
7048
- await refuseOnBreaks(actor, tenantId, scopeId, versionId, { acknowledge });
7134
+ await refuseOnBreaks(c.var.admin, actor, tenantId, scopeId, versionId, { acknowledge });
7049
7135
  await orchestratedSnapshot(c, tenantId, scope, {});
7050
7136
  }
7051
7137
  catch (e) {
@@ -7241,14 +7327,14 @@ export function createControlPlaneApi(options) {
7241
7327
  // registry row's owner_tenant (undefined ⇒ not registered — a claimable slug).
7242
7328
  // `listVerticals` returns every row (the adapter does not narrow); the transport is
7243
7329
  // where per-principal filtering happens — here and on GET /verticals.
7244
- const ownerOf = async (actor, slug) => {
7245
- const v = (await host.admin.listVerticals(actor)).find((x) => x.slug === slug);
7330
+ const ownerOf = async (admin, actor, slug) => {
7331
+ const v = (await admin.listVerticals(actor)).find((x) => x.slug === slug);
7246
7332
  return v ? v.ownerTenant : undefined;
7247
7333
  };
7248
7334
  // The full registry row, for the checks that need more than the owner — whether the
7249
7335
  // vertical is PRIVATE (owned + not listed), which is what scopes a builder's prod
7250
7336
  // self-serve below.
7251
- const verticalOf = async (actor, slug) => (await host.admin.listVerticals(actor)).find((x) => x.slug === slug);
7337
+ const verticalOf = async (admin, actor, slug) => (await admin.listVerticals(actor)).find((x) => x.slug === slug);
7252
7338
  /**
7253
7339
  * "This principal is confined, and this vertical is not its tenant's." The one
7254
7340
  * predicate every slug-addressed route asks.
@@ -7260,7 +7346,7 @@ export function createControlPlaneApi(options) {
7260
7346
  */
7261
7347
  const notOwned = async (p, slug) => {
7262
7348
  const pin = confinedTenant(p);
7263
- return pin !== null && (await ownerOf(p.actor, slug)) !== pin;
7349
+ return pin !== null && (await ownerOf(adminFor(p), p.actor, slug)) !== pin;
7264
7350
  };
7265
7351
  /**
7266
7352
  * The READ twin of `notOwned`, for the routes an installed app's own pages ask
@@ -7273,7 +7359,7 @@ export function createControlPlaneApi(options) {
7273
7359
  * the prod history).
7274
7360
  */
7275
7361
  /** The bare slug of every registered vertical: what a manager's SKU may not be (#2388). */
7276
- const verticalSlugsOf = async (actor) => (await host.admin.listVerticals(actor)).map((v) => bareSlug(v.slug));
7362
+ const verticalSlugsOf = async (admin, actor) => (await admin.listVerticals(actor)).map((v) => bareSlug(v.slug));
7277
7363
  /**
7278
7364
  * Whether a CONFINED principal may grant its own tenant `key` (#977): some vertical it
7279
7365
  * can read — its own, or a listed one — declares the key, or is the vertical whose
@@ -7288,8 +7374,8 @@ export function createControlPlaneApi(options) {
7288
7374
  * the first push). Its own slug and the engine keys are always declarable, so they reserve nothing.
7289
7375
  * Only a new slug is judged; the sentence names no other vertical.
7290
7376
  */
7291
- const slugClaimsSku = async (actor, slug) => {
7292
- const all = await host.admin.listVerticals(actor);
7377
+ const slugClaimsSku = async (admin, actor, slug) => {
7378
+ const all = await admin.listVerticals(actor);
7293
7379
  if (all.some((v) => v.slug === slug))
7294
7380
  return undefined;
7295
7381
  const name = bareSlug(slug);
@@ -7306,7 +7392,7 @@ export function createControlPlaneApi(options) {
7306
7392
  const pin = confinedTenant(p);
7307
7393
  if (pin === null)
7308
7394
  return true;
7309
- const all = await host.admin.listVerticals(p.actor);
7395
+ const all = await adminFor(p).listVerticals(p.actor);
7310
7396
  const slugs = all.map((v) => bareSlug(v.slug));
7311
7397
  const visible = all.filter((v) => v.ownerTenant === pin || v.listed);
7312
7398
  return visible.some((v) => {
@@ -7320,7 +7406,7 @@ export function createControlPlaneApi(options) {
7320
7406
  const pin = confinedTenant(p);
7321
7407
  if (pin === null)
7322
7408
  return false;
7323
- const v = await verticalOf(p.actor, slug);
7409
+ const v = await verticalOf(adminFor(p), p.actor, slug);
7324
7410
  return !v || (v.ownerTenant !== pin && !v.listed);
7325
7411
  };
7326
7412
  /**
@@ -7366,10 +7452,10 @@ export function createControlPlaneApi(options) {
7366
7452
  const workspace = (await c.var.admin.listTenants(actor)).find((t) => t.slug === pin || t.id === pin);
7367
7453
  if (!workspace || raw.startsWith(`${workspace.slug}/`))
7368
7454
  return raw;
7369
- if ((await ownerOf(actor, raw)) === workspace.id)
7455
+ if ((await ownerOf(c.var.admin, actor, raw)) === workspace.id)
7370
7456
  return raw;
7371
7457
  const prefixed = `${workspace.slug}/${raw}`;
7372
- return (await ownerOf(actor, prefixed)) !== undefined ? prefixed : raw;
7458
+ return (await ownerOf(c.var.admin, actor, prefixed)) !== undefined ? prefixed : raw;
7373
7459
  };
7374
7460
  app.get('/verticals', async (c) => {
7375
7461
  const page = pageParams(c);
@@ -7417,13 +7503,13 @@ export function createControlPlaneApi(options) {
7417
7503
  * same enumeration, and #977's observability narrowing must not be a second copy of
7418
7504
  * it that can drift when a third ref scheme appears.
7419
7505
  */
7420
- const tenantServiceRefs = async (actor, tenantId, slug) => {
7421
- const all = (await host.admin.listVerticals(actor)).filter((v) => v.ownerTenant === tenantId);
7506
+ const tenantServiceRefs = async (admin, actor, tenantId, slug) => {
7507
+ const all = (await admin.listVerticals(actor)).filter((v) => v.ownerTenant === tenantId);
7422
7508
  const considered = slug === undefined ? all : all.filter((v) => v.slug === slug);
7423
7509
  const verticalsTruncated = considered.length > MAX_SERVICE_REF_VERTICALS;
7424
7510
  const entries = [];
7425
7511
  for (const v of considered.slice(0, MAX_SERVICE_REF_VERTICALS)) {
7426
- const versions = await host.admin.listVersions(actor, v.slug);
7512
+ const versions = await admin.listVersions(actor, v.slug);
7427
7513
  const labelOf = new Map(versions.map((ver) => [ver.id, ver.version]));
7428
7514
  // The stable serving script — where real traffic lands. Its version is the
7429
7515
  // registry row's own servingVersionId: authoritative, not the scope-derived
@@ -7463,7 +7549,7 @@ export function createControlPlaneApi(options) {
7463
7549
  const pin = confinedTenant(p);
7464
7550
  if (pin === null)
7465
7551
  return null;
7466
- const refs = await tenantServiceRefs(actor, pin);
7552
+ const refs = await tenantServiceRefs(adminFor(p), actor, pin);
7467
7553
  if (refs.verticalsTruncated) {
7468
7554
  throw new ControlPlaneError(503, 'service map truncated: too many verticals to resolve in one answer');
7469
7555
  }
@@ -7482,7 +7568,7 @@ export function createControlPlaneApi(options) {
7482
7568
  .parse({ tenantId, vertical: c.req.query('vertical') });
7483
7569
  const actor = c.get('actor');
7484
7570
  const slug = query.vertical === undefined ? undefined : await resolveVerticalId(c, query.vertical);
7485
- const { entries, verticalsTruncated, matched } = await tenantServiceRefs(actor, query.tenantId, slug);
7571
+ const { entries, verticalsTruncated, matched } = await tenantServiceRefs(c.var.admin, actor, query.tenantId, slug);
7486
7572
  // Non-ownership reads as absence (K-3), exactly like the per-slug registry reads.
7487
7573
  if (slug !== undefined && matched === 0)
7488
7574
  throw new ControlPlaneError(404, 'not found');
@@ -7497,17 +7583,17 @@ export function createControlPlaneApi(options) {
7497
7583
  // the prefix, backstopped by the ownership check). Owner + prefix come from the
7498
7584
  // principal, never trusted from the body.
7499
7585
  input.slug = effectiveSlug(p, input.slug);
7500
- const owner = await ownerOf(p.actor, input.slug);
7586
+ const owner = await ownerOf(adminFor(p), p.actor, input.slug);
7501
7587
  if (owner !== undefined && owner !== p.tenantId)
7502
7588
  return refuse(c, 'forbidden', 'forbidden');
7503
7589
  input.ownerTenant = p.tenantId;
7504
7590
  }
7505
- const claimed = await slugClaimsSku(c.get('actor'), input.slug);
7591
+ const claimed = await slugClaimsSku(c.var.admin, c.get('actor'), input.slug);
7506
7592
  if (claimed)
7507
7593
  return refuseStatus(c, 422, claimed);
7508
7594
  // #2388: the same declaration rule as a push, for any principal that is not staff.
7509
7595
  if (p.kind !== 'staff') {
7510
- const foreignKey = entitlementNamespaceRefusal(input.slug, input.entitlements ?? [], input.provisions?.length ? await verticalSlugsOf(c.get('actor')) : undefined);
7596
+ const foreignKey = entitlementNamespaceRefusal(input.slug, input.entitlements ?? [], input.provisions?.length ? await verticalSlugsOf(c.var.admin, c.get('actor')) : undefined);
7511
7597
  if (foreignKey)
7512
7598
  return refuseStatus(c, 422, foreignKey);
7513
7599
  }
@@ -7593,9 +7679,12 @@ export function createControlPlaneApi(options) {
7593
7679
  }
7594
7680
  // #2210: publishing records the version's store bindings for the whole vertical's lifecycle
7595
7681
  // (backup, wipe, erasure, the meter), so its store scripts are put in place first, as a push
7596
- // does: a driver with no approved release refuses here, and nothing is published.
7597
- await ensureScopeStoresOf(c.get('actor'), 'deploy', slug, scopeStores, async () => (await ownerOf(c.get('actor'), slug)) ?? null);
7598
- await c.var.admin.publishVersion(c.get('actor'), input);
7682
+ // does: a driver with no approved release refuses here, and nothing is published. The publish is
7683
+ // made under the push's shared hold (#2816), so a rollout walks this vertical or waits for it.
7684
+ await ensureScopeStoresOf(c.var.admin, c.get('actor'), 'deploy', slug, scopeStores, async () => (await ownerOf(c.var.admin, c.get('actor'), slug)) ?? null, async (renew) => {
7685
+ await renew();
7686
+ await c.var.admin.publishVersion(c.get('actor'), input);
7687
+ });
7599
7688
  const version = await c.var.admin.getVersion(c.get('actor'), input.id, slug);
7600
7689
  return c.json(version, 201);
7601
7690
  });
@@ -7813,7 +7902,7 @@ export function createControlPlaneApi(options) {
7813
7902
  const slug = c.req.param('slug');
7814
7903
  if (await notOwned(c.get('principal'), slug))
7815
7904
  return refuse(c, 'forbidden', 'forbidden');
7816
- const vertical = await verticalOf(c.get('actor'), slug);
7905
+ const vertical = await verticalOf(c.var.admin, c.get('actor'), slug);
7817
7906
  if (!vertical)
7818
7907
  return refuse(c, 'not_found', 'vertical not found');
7819
7908
  return c.json({ slug, emailSender: !!vertical.emailSender });
@@ -7892,10 +7981,16 @@ export function createControlPlaneApi(options) {
7892
7981
  * (#559), then rethrown, so the push or serve answers it and the vertical upload never runs.
7893
7982
  * The row carries the vertical's owner, so it shows in that tenant's failure view; the serve
7894
7983
  * path reads the owner only once something has failed.
7984
+ *
7985
+ * `then` runs after the uploads, still under the shared holds (#2816): a push or a publish passes
7986
+ * the step that records the version's store declarations, so a rollout either finds the vertical
7987
+ * in what it walks or is refused until that step is done. `renew` renews every hold, and refuses
7988
+ * (409) once one has lapsed; `then` calls it right before it records. A failure in `then` is not
7989
+ * recorded here: it is the caller's to answer.
7895
7990
  */
7896
- const ensureScopeStoresOf = async (actor, operation, slug, stores, owner) => {
7991
+ const ensureScopeStoresOf = async (admin, actor, operation, slug, stores, owner, then = async () => undefined) => {
7897
7992
  if (stores.length === 0)
7898
- return;
7993
+ return then(async () => undefined);
7899
7994
  // D-67: a driver whose registration names its verticals serves those only, refused before any
7900
7995
  // hold is taken or anything is uploaded.
7901
7996
  for (const store of stores) {
@@ -7907,54 +8002,61 @@ export function createControlPlaneApi(options) {
7907
8002
  const holder = ulid();
7908
8003
  const drivers = [...new Set(stores.map((store) => store.driver))].sort();
7909
8004
  const held = [];
8005
+ const renewHolds = async (when) => {
8006
+ for (const each of held) {
8007
+ if (!(await admin.holdStoreDriver(actor, { driver: each, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS, renew: true }))) {
8008
+ throw new ControlPlaneError(409, `${operation} refused: the hold on scope store driver '${each}' lapsed ${when}; try again`);
8009
+ }
8010
+ }
8011
+ };
8012
+ const renew = () => renewHolds('during the upload');
7910
8013
  try {
7911
- if (!options.ensureScopeStores) {
7912
- throw new ControlPlaneError(501, `${operation} refused: '${slug}' declares a scope store and this control plane uploads no store script`);
7913
- }
7914
- // A shared hold on each driver (#2300): a rollout holds it exclusively, and a rebuild that read
7915
- // a script's release before a rollout moved it would upload the old release back over it.
7916
- // Neither the refusal nor anything else here names another vertical: a push answers its own.
7917
- for (const driver of drivers) {
7918
- if (!(await host.admin.holdStoreDriver(actor, { driver, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS }))) {
7919
- throw new ControlPlaneError(409, `${operation} refused: scope store driver '${driver}' is being rolled out; try again in a few minutes`);
8014
+ try {
8015
+ if (!options.ensureScopeStores) {
8016
+ throw new ControlPlaneError(501, `${operation} refused: '${slug}' declares a scope store and this control plane uploads no store script`);
7920
8017
  }
7921
- held.push(driver);
7922
- }
7923
- const loadRelease = options.storeReleases
7924
- ? storedReleaseLoader({ admin: host.admin, actor, bytes: options.storeReleases.bytes })
7925
- : async () => undefined;
7926
- // One driver at a time, each renewing every hold first: a push whose uploads outlast a hold
7927
- // stops (409) rather than upload after a rollout could have taken the driver.
7928
- for (const driver of drivers) {
7929
- for (const each of held) {
7930
- if (!(await host.admin.holdStoreDriver(actor, { driver: each, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS, renew: true }))) {
7931
- throw new ControlPlaneError(409, `${operation} refused: the hold on scope store driver '${each}' lapsed during the upload; try again`);
8018
+ // A shared hold on each driver (#2300): a rollout holds it exclusively, and a rebuild that read
8019
+ // a script's release before a rollout moved it would upload the old release back over it.
8020
+ // Neither the refusal nor anything else here names another vertical: a push answers its own.
8021
+ for (const driver of drivers) {
8022
+ if (!(await admin.holdStoreDriver(actor, { driver, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS }))) {
8023
+ throw new ControlPlaneError(409, `${operation} refused: scope store driver '${driver}' is being rolled out; try again in a few minutes`);
7932
8024
  }
8025
+ held.push(driver);
8026
+ }
8027
+ const loadRelease = options.storeReleases
8028
+ ? storedReleaseLoader({ admin, actor, bytes: options.storeReleases.bytes })
8029
+ : async () => undefined;
8030
+ // One driver at a time, each renewing every hold first: a push whose uploads outlast a hold
8031
+ // stops (409) rather than upload after a rollout could have taken the driver.
8032
+ for (const driver of drivers) {
8033
+ await renew();
8034
+ const registration = (options.storeDrivers ?? STORE_DRIVERS).find((k) => k.driver === driver);
8035
+ if (!registration)
8036
+ throw new ControlPlaneError(403, `scope store driver '${driver}' is not registered`);
8037
+ // Read under the shared hold (D-65): a revoke takes the driver exclusively, so one written
8038
+ // before this read is seen, and none is written until this upload is done.
8039
+ const approvals = await readStoreDriverApprovals(admin, actor, registration);
8040
+ await options.ensureScopeStores({ vertical: slug, stores: stores.filter((store) => store.driver === driver), loadRelease, approvals });
7933
8041
  }
7934
- const registration = (options.storeDrivers ?? STORE_DRIVERS).find((k) => k.driver === driver);
7935
- if (!registration)
7936
- throw new ControlPlaneError(403, `scope store driver '${driver}' is not registered`);
7937
- // Read under the shared hold (D-65): a revoke takes the driver exclusively, so one written
7938
- // before this read is seen, and none is written until this upload is done.
7939
- const approvals = await readStoreDriverApprovals(host.admin, actor, registration);
7940
- await options.ensureScopeStores({ vertical: slug, stores: stores.filter((store) => store.driver === driver), loadRelease, approvals });
7941
8042
  }
7942
- }
7943
- catch (e) {
7944
- recordFailure({
7945
- actor,
7946
- operation: `${operation}.store-upload`,
7947
- stage: 'store-upload',
7948
- vertical: slug,
7949
- tenantId: await owner().catch(() => null),
7950
- status: e instanceof ControlPlaneError ? e.status : 502,
7951
- message: e instanceof Error ? e.message : String(e),
7952
- }, e);
7953
- throw e;
8043
+ catch (e) {
8044
+ recordFailure({
8045
+ actor,
8046
+ operation: `${operation}.store-upload`,
8047
+ stage: 'store-upload',
8048
+ vertical: slug,
8049
+ tenantId: await owner().catch(() => null),
8050
+ status: e instanceof ControlPlaneError ? e.status : 502,
8051
+ message: e instanceof Error ? e.message : String(e),
8052
+ }, e);
8053
+ throw e;
8054
+ }
8055
+ return await then(() => renewHolds('before the version was recorded'));
7954
8056
  }
7955
8057
  finally {
7956
8058
  for (const driver of held)
7957
- await host.admin.releaseStoreDriverHold(actor, { driver, holder }).catch(() => undefined);
8059
+ await admin.releaseStoreDriverHold(actor, { driver, holder }).catch(() => undefined);
7958
8060
  }
7959
8061
  };
7960
8062
  /**
@@ -8059,12 +8161,12 @@ export function createControlPlaneApi(options) {
8059
8161
  const serveVersionInPlace = async (admin, actor, slug, versionId) => {
8060
8162
  if (!options.deployVertical || !options.fetchVerticalModules)
8061
8163
  return; // not configured: pre-#286 behavior
8062
- const version = await host.admin.getVersion(actor, versionId, slug);
8164
+ const version = await admin.getVersion(actor, versionId, slug);
8063
8165
  if (!version?.deploymentRef) {
8064
8166
  throw new ControlPlaneError(502, `version ${versionId} has no archive script to serve from`);
8065
8167
  }
8066
8168
  const archiveRef = version.deploymentRef;
8067
- const manifestJson = await host.admin.versionManifest(actor, slug, versionId);
8169
+ const manifestJson = await admin.versionManifest(actor, slug, versionId);
8068
8170
  if (!manifestJson) {
8069
8171
  throw new ControlPlaneError(502, `version ${versionId} retained no manifest — pushed pre-#286; push it again to serve in place`);
8070
8172
  }
@@ -8074,7 +8176,7 @@ export function createControlPlaneApi(options) {
8074
8176
  // serving record below all read this one set; the manifest's own `doClasses` would leave
8075
8177
  // the record a class short of the script, and the next serve re-declaring a live class.
8076
8178
  const doClasses = deployedDoClasses(manifest.doClasses, manifest.platformSweeper);
8077
- const serving = await host.admin.verticalServing(actor, slug);
8179
+ const serving = await admin.verticalServing(actor, slug);
8078
8180
  const ref = serving?.ref ?? stableDeploymentRefFor(slug);
8079
8181
  // #1923: the serving upload is the other door a script name is minted through.
8080
8182
  const collision = platformScriptCollision(slug) ?? (PLATFORM_SCRIPT_NAMES.has(ref) ? ref : null);
@@ -8088,16 +8190,16 @@ export function createControlPlaneApi(options) {
8088
8190
  // tenant's store. Platform-granted after the §4 sandbox check, like injectSecrets:
8089
8191
  // the builder never declared these and never named the ids.
8090
8192
  const storeBindings = [
8091
- ...tenantStoreBindings(await host.admin.listTenantStores(actor, { vertical: slug })),
8193
+ ...tenantStoreBindings(await admin.listTenantStores(actor, { vertical: slug })),
8092
8194
  // #473: the per-tenant blob-store r2_bucket bindings, re-derived from the ledger on
8093
8195
  // every serving upload for the same reason — an upload replaces the script's binding
8094
8196
  // set, so a re-deploy must never drop a tenant's attachment bucket.
8095
- ...blobStoreBindings(await host.admin.listBlobStores(actor, { vertical: slug })),
8197
+ ...blobStoreBindings(await admin.listBlobStores(actor, { vertical: slug })),
8096
8198
  ].map((b) => b.type === 'd1'
8097
8199
  ? { type: 'd1', name: b.name, id: b.id }
8098
8200
  : { type: 'r2_bucket', name: b.name, bucket_name: b.bucketName });
8099
8201
  // #2210: the store scripts first, so the vertical is never served before what it reaches.
8100
- await ensureScopeStoresOf(actor, 'serve', slug, manifest.scopeStores, async () => (await ownerOf(actor, slug)) ?? null);
8202
+ await ensureScopeStoresOf(admin, actor, 'serve', slug, manifest.scopeStores, async () => (await ownerOf(admin, actor, slug)) ?? null);
8101
8203
  const wire = await deployVerticalScript(options.deployVertical, actor, slug, ref, {
8102
8204
  entry: manifest.entry,
8103
8205
  compatibilityDate: manifest.compatibilityDate,
@@ -8259,14 +8361,14 @@ export function createControlPlaneApi(options) {
8259
8361
  if (!options.deployVertical || !options.fetchVerticalModules)
8260
8362
  return none; // no in-place serve moves anything
8261
8363
  const actor = c.get('actor');
8262
- const incoming = await host.admin.getVersion(actor, versionId, slug);
8364
+ const incoming = await c.var.admin.getVersion(actor, versionId, slug);
8263
8365
  if (!incoming?.deploymentRef)
8264
8366
  return none;
8265
- const manifestJson = await host.admin.versionManifest(actor, slug, versionId);
8367
+ const manifestJson = await c.var.admin.versionManifest(actor, slug, versionId);
8266
8368
  const registry = manifestJson ? storedDeployManifest.parse(JSON.parse(manifestJson)).registry : undefined;
8267
8369
  if (!registry || !releaseRolesAskScopes(registry))
8268
8370
  return none;
8269
- const v = await verticalOf(actor, slug);
8371
+ const v = await verticalOf(c.var.admin, actor, slug);
8270
8372
  const owner = v && v.ownerTenant !== null && !v.listed ? v.ownerTenant : null;
8271
8373
  const moved = (await c.var.admin.listScopes(actor, { vertical: slug, status: ['active', 'suspended'] }))
8272
8374
  .filter((s) => !s.forkedFrom && s.kind !== 'preview' && (!!s.servingRef || (s.tenantId === owner && s.status === 'active')))
@@ -8320,23 +8422,23 @@ export function createControlPlaneApi(options) {
8320
8422
  * Over-approximates for a host that cannot serve in place, where no adopt runs: an extra line
8321
8423
  * costs a read, a missing one a silent stall.
8322
8424
  */
8323
- const promoteImpactOf = async (actor, slug, channel, versionId) => {
8324
- const gate = await host.admin.promotionImpact(actor, slug, channel, versionId);
8425
+ const promoteImpactOf = async (admin, actor, slug, channel, versionId) => {
8426
+ const gate = await admin.promotionImpact(actor, slug, channel, versionId);
8325
8427
  if (channel !== 'prod')
8326
8428
  return gate;
8327
- const v = await verticalOf(actor, slug);
8429
+ const v = await verticalOf(admin, actor, slug);
8328
8430
  if (!v || v.ownerTenant === null || v.listed)
8329
8431
  return gate;
8330
- const incoming = await host.admin.getVersion(actor, versionId, slug);
8432
+ const incoming = await admin.getVersion(actor, versionId, slug);
8331
8433
  if (!incoming?.deploymentRef || incoming.admission !== 'admitted')
8332
8434
  return gate;
8333
- const lagging = (await host.admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug, status: ['active'] })).filter((s) => !s.forkedFrom && s.kind !== 'preview' && !s.servingRef && s.verticalVersionId !== versionId);
8435
+ const lagging = (await admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug, status: ['active'] })).filter((s) => !s.forkedFrom && s.kind !== 'preview' && !s.servingRef && s.verticalVersionId !== versionId);
8334
8436
  const seen = new Set(gate.map((b) => `${b.scopeId}|${b.type}`));
8335
8437
  const out = [...gate];
8336
8438
  for (const s of lagging) {
8337
8439
  // After the adopt it runs the promoted version, served in place: that pointer move, from
8338
8440
  // what it runs now, is the move to judge.
8339
- for (const b of await host.admin.bindingImpact(actor, s.tenantId, s.id, versionId)) {
8441
+ for (const b of await admin.bindingImpact(actor, s.tenantId, s.id, versionId)) {
8340
8442
  const key = `${b.scopeId}|${b.type}`;
8341
8443
  if (!seen.has(key))
8342
8444
  (seen.add(key), out.push(b));
@@ -8355,11 +8457,11 @@ export function createControlPlaneApi(options) {
8355
8457
  const versionId = z.string().min(1).parse(c.req.query('versionId'));
8356
8458
  const pin = confinedTenant(p);
8357
8459
  if (pin) {
8358
- const v = await verticalOf(p.actor, slug);
8460
+ const v = await verticalOf(adminFor(p), p.actor, slug);
8359
8461
  if (!v || v.ownerTenant !== pin)
8360
8462
  return refuse(c, 'forbidden', 'forbidden');
8361
8463
  }
8362
- const breaks = await promoteImpactOf(c.get('actor'), slug, channel, versionId);
8464
+ const breaks = await promoteImpactOf(c.var.admin, c.get('actor'), slug, channel, versionId);
8363
8465
  const { visible, otherTenants } = narrowToCaller(breaks, pin);
8364
8466
  return c.json({ affected: visible, ...(otherTenants ? { otherTenants } : {}) });
8365
8467
  });
@@ -8377,7 +8479,7 @@ export function createControlPlaneApi(options) {
8377
8479
  const promotePin = confinedTenant(p);
8378
8480
  if (promotePin) {
8379
8481
  // A confined principal promotes only verticals its tenant owns.
8380
- const v = await verticalOf(p.actor, slug);
8482
+ const v = await verticalOf(adminFor(p), p.actor, slug);
8381
8483
  if (!v || v.ownerTenant !== promotePin)
8382
8484
  return refuse(c, 'forbidden', 'forbidden');
8383
8485
  // …and a BUILDER promotes prod only while the vertical is PRIVATE (not listed).
@@ -8411,7 +8513,7 @@ export function createControlPlaneApi(options) {
8411
8513
  // counts only the gate's half of them.
8412
8514
  let counted = [];
8413
8515
  const breaksOf = async () => {
8414
- const breaks = await promoteImpactOf(c.get('actor'), slug, channel, versionId);
8516
+ const breaks = await promoteImpactOf(c.var.admin, c.get('actor'), slug, channel, versionId);
8415
8517
  counted = breaks;
8416
8518
  if (breaks.length === 0)
8417
8519
  return null;
@@ -8582,9 +8684,9 @@ export function createControlPlaneApi(options) {
8582
8684
  if (c.req.method === 'POST') {
8583
8685
  const input = z.object({ slug: z.string().min(1), name: z.string().min(1), repository: z.unknown().optional() }).parse(await c.req.json());
8584
8686
  const team = tenants.find((t) => t.id === tenant);
8585
- const slug = await ownerOf(c.get('actor'), input.slug) === tenant || input.slug.startsWith(`${team.slug}/`) ? input.slug : `${team.slug}/${input.slug}`;
8687
+ const slug = await ownerOf(c.var.admin, c.get('actor'), input.slug) === tenant || input.slug.startsWith(`${team.slug}/`) ? input.slug : `${team.slug}/${input.slug}`;
8586
8688
  verticalSlug.parse(slug);
8587
- const owner = await ownerOf(c.get('actor'), slug);
8689
+ const owner = await ownerOf(c.var.admin, c.get('actor'), slug);
8588
8690
  if (owner !== undefined && owner !== tenant)
8589
8691
  return refuse(c, 'forbidden', 'forbidden');
8590
8692
  body = JSON.stringify({ ...input, slug });
@@ -8639,9 +8741,9 @@ export function createControlPlaneApi(options) {
8639
8741
  if (!tenant)
8640
8742
  return refuse(c, 'not_found', 'unknown workspace');
8641
8743
  sourceTenant = tenant.id;
8642
- sourceSlug = bare.startsWith(`${tenant.slug}/`) || await ownerOf(c.get('actor'), bare) === tenant.id ? bare : `${tenant.slug}/${bare}`;
8744
+ sourceSlug = bare.startsWith(`${tenant.slug}/`) || await ownerOf(c.var.admin, c.get('actor'), bare) === tenant.id ? bare : `${tenant.slug}/${bare}`;
8643
8745
  }
8644
- const owner = await ownerOf(c.get('actor'), sourceSlug);
8746
+ const owner = await ownerOf(c.var.admin, c.get('actor'), sourceSlug);
8645
8747
  if (sourceTenant && owner !== undefined && owner !== sourceTenant)
8646
8748
  return refuse(c, 'forbidden', 'forbidden');
8647
8749
  sourceTenant ??= owner ?? null;
@@ -8733,7 +8835,7 @@ export function createControlPlaneApi(options) {
8733
8835
  }
8734
8836
  slug = effectiveSlug(p, bare);
8735
8837
  // A builder pushes to a slug it owns, or claims an unregistered one (§3).
8736
- const existingOwner = await ownerOf(c.get('actor'), slug);
8838
+ const existingOwner = await ownerOf(c.var.admin, c.get('actor'), slug);
8737
8839
  if (existingOwner !== undefined && existingOwner !== p.tenantId) {
8738
8840
  return refuse(c, 'forbidden', 'forbidden');
8739
8841
  }
@@ -8747,9 +8849,9 @@ export function createControlPlaneApi(options) {
8747
8849
  // hand-registration predating prefixed claims) stays addressable as itself.
8748
8850
  // Otherwise the claim lands under the tenant prefix, exactly like a builder push —
8749
8851
  // same namespace, no claim race with other workspaces' bare names.
8750
- const bareOwner = await ownerOf(c.get('actor'), bare);
8852
+ const bareOwner = await ownerOf(c.var.admin, c.get('actor'), bare);
8751
8853
  slug = bare.startsWith(`${workspace.slug}/`) || bareOwner === workspace.id ? bare : `${workspace.slug}/${bare}`;
8752
- const existingOwner = slug === bare ? bareOwner : await ownerOf(c.get('actor'), slug);
8854
+ const existingOwner = slug === bare ? bareOwner : await ownerOf(c.var.admin, c.get('actor'), slug);
8753
8855
  if (existingOwner !== undefined && existingOwner !== workspace.id) {
8754
8856
  return refuse(c, 'forbidden', `vertical '${slug}' is not owned by workspace '${pin}'`);
8755
8857
  }
@@ -8759,7 +8861,7 @@ export function createControlPlaneApi(options) {
8759
8861
  // No pin: the raw slug is the identity and an existing owner is preserved
8760
8862
  // (null ⇒ platform-owned for a first-party vertical) — unchanged staff behavior.
8761
8863
  slug = bare;
8762
- ownerTenant = (await ownerOf(c.get('actor'), slug)) ?? null;
8864
+ ownerTenant = (await ownerOf(c.var.admin, c.get('actor'), slug)) ?? null;
8763
8865
  }
8764
8866
  // #1923: a slug that would deploy under one of the platform's own script names is refused
8765
8867
  // before anything is uploaded. Registration refuses it too; this is the deploy half, for a
@@ -8837,10 +8939,10 @@ export function createControlPlaneApi(options) {
8837
8939
  // is judged here and honoured by the grant paths only for a tenant-provisioner. Earlier versions
8838
8940
  // are not re-judged.
8839
8941
  // Declaring `provisions` marks a manager: the capability itself is granted after a first push.
8840
- const foreignKey = entitlementNamespaceRefusal(slug, manifest.entitlements ?? [], manifest.provisions?.length ? await verticalSlugsOf(c.get('actor')) : undefined);
8942
+ const foreignKey = entitlementNamespaceRefusal(slug, manifest.entitlements ?? [], manifest.provisions?.length ? await verticalSlugsOf(c.var.admin, c.get('actor')) : undefined);
8841
8943
  if (foreignKey)
8842
8944
  return refuseStatus(c, 422, foreignKey);
8843
- const claimed = await slugClaimsSku(c.get('actor'), slug);
8945
+ const claimed = await slugClaimsSku(c.var.admin, c.get('actor'), slug);
8844
8946
  if (claimed)
8845
8947
  return refuseStatus(c, 422, claimed);
8846
8948
  // The static-asset half of the §4 sandbox contract (self-serve-deploy.md §4.1). The
@@ -8907,117 +9009,128 @@ export function createControlPlaneApi(options) {
8907
9009
  const id = ulid();
8908
9010
  const deploymentRef = deploymentRefFor(slug, id);
8909
9011
  // #2210: the store scripts first, outside the vertical upload's try, so a store failure
8910
- // answers as itself and the vertical is not uploaded at all.
8911
- await ensureScopeStoresOf(c.get('actor'), 'deploy', slug, manifest.scopeStores, async () => ownerTenant ?? null);
8912
- let wire;
8913
- try {
8914
- wire = await deployVerticalScript(options.deployVertical, c.get('actor'), slug, deploymentRef, {
8915
- entry: manifest.entry,
8916
- compatibilityDate: manifest.compatibilityDate,
8917
- compatibilityFlags: manifest.compatibilityFlags,
8918
- modules,
8919
- doClasses: manifest.doClasses,
8920
- bindings: verticalUploadBindings({
9012
+ // answers as itself and the vertical is not uploaded at all. The upload, the registration and
9013
+ // the publish run under the store push's shared hold (#2816): a rollout that starts before the
9014
+ // version records its stores is refused, and one after walks this vertical.
9015
+ const deployVertical = options.deployVertical;
9016
+ const pushed = await ensureScopeStoresOf(c.var.admin, c.get('actor'), 'deploy', slug, manifest.scopeStores, async () => ownerTenant ?? null, async (renew) => {
9017
+ let wire;
9018
+ try {
9019
+ wire = await deployVerticalScript(deployVertical, c.get('actor'), slug, deploymentRef, {
9020
+ entry: manifest.entry,
9021
+ compatibilityDate: manifest.compatibilityDate,
9022
+ compatibilityFlags: manifest.compatibilityFlags,
9023
+ modules,
9024
+ doClasses: manifest.doClasses,
9025
+ bindings: verticalUploadBindings({
9026
+ vertical: slug,
9027
+ manifest,
9028
+ drivers: options.storeDrivers,
9029
+ // #2349: the migrations this push declares, which are the ones the version stores.
9030
+ platformBindings: await migrationsDigestBindings(manifest.migrations ?? null),
9031
+ }),
9032
+ // #1242: the freshly minted version id — this archive script IS that version,
9033
+ // and a preview routed at it stamps the signals `version` dimension honestly.
9034
+ versionId: id,
9035
+ // #1902: the platform's scope sweeper, as decided above and recorded with the version.
9036
+ ...(sweeper.supply ? { supplySweeper: true } : {}),
9037
+ // #340: the verified bytes go up with the bundle. The manifest's routing config
9038
+ // rides along untouched — it decides what the RUNTIME does with paths, and carries
9039
+ // no reach, so there is nothing in it for the sandbox contract to refuse.
9040
+ ...(manifest.assets ? { assets: { ...manifest.assets, files: assets } } : {}),
9041
+ });
9042
+ }
9043
+ catch (e) {
9044
+ // The upload to the runtime failed. Surface the detail (the builder is
9045
+ // authenticated) rather than the anonymous 500 the generic handler would give, so a
9046
+ // push failure is diagnosable without reading worker logs. The version label is NOT
9047
+ // consumed here: registration/publish happen only AFTER a successful upload (below),
9048
+ // so a failed push leaves the same --version reusable (#307).
9049
+ //
9050
+ // Answer the upstream status honestly: a runtime 4xx is a bad-bundle rejection — the
9051
+ // builder's own script (e.g. a module-top-level throw → CF 10021), well-formed HTTP
9052
+ // but refused — so a 422, not a 502 that reads as a platform outage. A 5xx (or any
9053
+ // throw with no upstream status) is a platform failure and stays a 502.
9054
+ const detail = e instanceof Error ? e.message : String(e);
9055
+ const upstream = upstreamStatusOf(e);
9056
+ console.error('deploy.upload.failed', { slug, deploymentRef, detail, upstream });
9057
+ const rejected = upstream !== undefined && upstream >= 400 && upstream < 500;
9058
+ // Both outcomes land an ops-failure row (#559): the 502 is the platform's to
9059
+ // explain, and the 422 is what the builder-facing failure view (step 5) will
9060
+ // list — a red push should be explainable from a durable record either way.
9061
+ recordFailure({
9062
+ actor: c.get('actor'),
9063
+ operation: 'deploy.upload',
9064
+ stage: 'wfp-upload',
8921
9065
  vertical: slug,
8922
- manifest,
8923
- drivers: options.storeDrivers,
8924
- // #2349: the migrations this push declares, which are the ones the version stores.
8925
- platformBindings: await migrationsDigestBindings(manifest.migrations ?? null),
8926
- }),
8927
- // #1242: the freshly minted version id — this archive script IS that version,
8928
- // and a preview routed at it stamps the signals `version` dimension honestly.
8929
- versionId: id,
8930
- // #1902: the platform's scope sweeper, as decided above and recorded with the version.
8931
- ...(sweeper.supply ? { supplySweeper: true } : {}),
8932
- // #340: the verified bytes go up with the bundle. The manifest's routing config
8933
- // rides along untouched — it decides what the RUNTIME does with paths, and carries
8934
- // no reach, so there is nothing in it for the sandbox contract to refuse.
8935
- ...(manifest.assets ? { assets: { ...manifest.assets, files: assets } } : {}),
9066
+ tenantId: ownerTenant ?? null,
9067
+ status: rejected ? 422 : 502,
9068
+ message: detail,
9069
+ }, e);
9070
+ c.set('failureRecorded', true);
9071
+ return rejected
9072
+ ? refuseStatus(c, 422, 'deploy rejected', { detail })
9073
+ : refuseStatus(c, 502, 'deploy upload failed', { detail });
9074
+ }
9075
+ // Register-then-publish, both idempotent-ish below the seam: a first push of a
9076
+ // slug registers it; publishVersion lands the version pending with deploymentRef
9077
+ // (or admitted, for a PRIVATE vertical — the registry's self-admit rule). The owner
9078
+ // was resolved with the slug above: the builder's tenant, the pinned workspace, or
9079
+ // the preserved existing owner for an unpinned staff push. An upload that outlasted the
9080
+ // store hold refuses here (409) before the vertical or its version is registered, so the
9081
+ // label stays reusable; unlike a failed upload, it lands no ops-failure row.
9082
+ await renew();
9083
+ await c.var.admin.registerVertical(c.get('actor'), {
9084
+ slug,
9085
+ name: manifest.name ?? slug,
9086
+ source: 'cli',
9087
+ ownerTenant,
9088
+ // The vertical's declared config surface rides to the registry, so the dashboard
9089
+ // renders a settings form for a pushed vertical exactly like a builtin.
9090
+ ...(manifest.envSpec ? { envSpec: manifest.envSpec } : {}),
9091
+ // Registry-driven install metadata (marketplace-publish.md §3) — so the dashboard
9092
+ // installs a pushed vertical without a hardcoded catalog entry.
9093
+ ...(manifest.ownerGrants ? { ownerGrants: manifest.ownerGrants } : {}),
9094
+ ...(manifest.entitlements ? { entitlements: manifest.entitlements } : {}),
9095
+ ...(manifest.provides ? { provides: manifest.provides } : {}),
9096
+ ...(manifest.requires ? { requires: manifest.requires } : {}),
9097
+ // The declared vertical-defined connectors (vertical-connectors.md §7.2): what the dashboard's
9098
+ // door renders. The proxy never reads this copy; it reads the running version's manifest.
9099
+ ...(manifest.connectors ? { connectors: manifest.connectors } : {}),
9100
+ // The declared provisioner intent (#455) — a REQUEST the console reviews; the
9101
+ // tenant-provisioner grant itself is never touched by a push. Refreshes on every
9102
+ // re-push like the rest of the install spec, exactly because it grants nothing.
9103
+ ...(manifest.provisions ? { provisions: manifest.provisions } : {}),
9104
+ // The declared email-sender intent (#303) — a REQUEST the console reviews; the
9105
+ // `emailSender` grant itself is never touched by a push. Refreshes on every re-push
9106
+ // like the rest of the install spec, exactly because it grants nothing.
9107
+ ...(manifest.sendsEmail ? { sendsEmail: true } : {}),
9108
+ // Where the scope lifecycle is held (#1713): `'router'` keeps the vertical's scopes out of
9109
+ // the lifecycle delivery. Refreshes on every re-push like the rest of the install spec.
9110
+ ...(manifest.lifecycle ? { lifecycle: manifest.lifecycle } : {}),
9111
+ // The declared surfaces (K-26) ride like envSpec: registry metadata for the
9112
+ // hostname-binding picker, never behavior. Not part of any admission digest.
9113
+ ...(manifest.surfaces ? { surfaces: manifest.surfaces } : {}),
8936
9114
  });
8937
- }
8938
- catch (e) {
8939
- // The upload to the runtime failed. Surface the detail (the builder is
8940
- // authenticated) rather than the anonymous 500 the generic handler would give, so a
8941
- // push failure is diagnosable without reading worker logs. The version label is NOT
8942
- // consumed here: registration/publish happen only AFTER a successful upload (below),
8943
- // so a failed push leaves the same --version reusable (#307).
8944
- //
8945
- // Answer the upstream status honestly: a runtime 4xx is a bad-bundle rejection — the
8946
- // builder's own script (e.g. a module-top-level throw → CF 10021), well-formed HTTP
8947
- // but refused — so a 422, not a 502 that reads as a platform outage. A 5xx (or any
8948
- // throw with no upstream status) is a platform failure and stays a 502.
8949
- const detail = e instanceof Error ? e.message : String(e);
8950
- const upstream = upstreamStatusOf(e);
8951
- console.error('deploy.upload.failed', { slug, deploymentRef, detail, upstream });
8952
- const rejected = upstream !== undefined && upstream >= 400 && upstream < 500;
8953
- // Both outcomes land an ops-failure row (#559): the 502 is the platform's to
8954
- // explain, and the 422 is what the builder-facing failure view (step 5) will
8955
- // list — a red push should be explainable from a durable record either way.
8956
- recordFailure({
8957
- actor: c.get('actor'),
8958
- operation: 'deploy.upload',
8959
- stage: 'wfp-upload',
8960
- vertical: slug,
8961
- tenantId: ownerTenant ?? null,
8962
- status: rejected ? 422 : 502,
8963
- message: detail,
8964
- }, e);
8965
- c.set('failureRecorded', true);
8966
- return rejected
8967
- ? refuseStatus(c, 422, 'deploy rejected', { detail })
8968
- : refuseStatus(c, 502, 'deploy upload failed', { detail });
8969
- }
8970
- // Register-then-publish, both idempotent-ish below the seam: a first push of a
8971
- // slug registers it; publishVersion lands the version pending with deploymentRef
8972
- // (or admitted, for a PRIVATE vertical — the registry's self-admit rule). The owner
8973
- // was resolved with the slug above: the builder's tenant, the pinned workspace, or
8974
- // the preserved existing owner for an unpinned staff push.
8975
- await c.var.admin.registerVertical(c.get('actor'), {
8976
- slug,
8977
- name: manifest.name ?? slug,
8978
- source: 'cli',
8979
- ownerTenant,
8980
- // The vertical's declared config surface rides to the registry, so the dashboard
8981
- // renders a settings form for a pushed vertical exactly like a builtin.
8982
- ...(manifest.envSpec ? { envSpec: manifest.envSpec } : {}),
8983
- // Registry-driven install metadata (marketplace-publish.md §3) — so the dashboard
8984
- // installs a pushed vertical without a hardcoded catalog entry.
8985
- ...(manifest.ownerGrants ? { ownerGrants: manifest.ownerGrants } : {}),
8986
- ...(manifest.entitlements ? { entitlements: manifest.entitlements } : {}),
8987
- ...(manifest.provides ? { provides: manifest.provides } : {}),
8988
- ...(manifest.requires ? { requires: manifest.requires } : {}),
8989
- // The declared vertical-defined connectors (vertical-connectors.md §7.2): what the dashboard's
8990
- // door renders. The proxy never reads this copy; it reads the running version's manifest.
8991
- ...(manifest.connectors ? { connectors: manifest.connectors } : {}),
8992
- // The declared provisioner intent (#455) — a REQUEST the console reviews; the
8993
- // tenant-provisioner grant itself is never touched by a push. Refreshes on every
8994
- // re-push like the rest of the install spec, exactly because it grants nothing.
8995
- ...(manifest.provisions ? { provisions: manifest.provisions } : {}),
8996
- // The declared email-sender intent (#303) — a REQUEST the console reviews; the
8997
- // `emailSender` grant itself is never touched by a push. Refreshes on every re-push
8998
- // like the rest of the install spec, exactly because it grants nothing.
8999
- ...(manifest.sendsEmail ? { sendsEmail: true } : {}),
9000
- // Where the scope lifecycle is held (#1713): `'router'` keeps the vertical's scopes out of
9001
- // the lifecycle delivery. Refreshes on every re-push like the rest of the install spec.
9002
- ...(manifest.lifecycle ? { lifecycle: manifest.lifecycle } : {}),
9003
- // The declared surfaces (K-26) ride like envSpec: registry metadata for the
9004
- // hostname-binding picker, never behavior. Not part of any admission digest.
9005
- ...(manifest.surfaces ? { surfaces: manifest.surfaces } : {}),
9006
- });
9007
- await c.var.admin.publishVersion(c.get('actor'), {
9008
- id,
9009
- verticalSlug: slug,
9010
- version: manifest.version,
9011
- manifestDigest: manifest.digests.manifest,
9012
- permissionDigest: manifest.digests.permission,
9013
- migrationDigest: manifest.digests.migration,
9014
- deploymentRef,
9015
- ...(origin ? { origin } : {}),
9016
- // Retained for the serving upload (#286): the archive script keeps the module
9017
- // bytes, this keeps their shape (entry, compat, doClasses, bindings).
9018
- // #1902: with the sweeper decision the push made, which no later upload re-makes.
9019
- manifestJson: JSON.stringify({ ...manifest, platformSweeper: sweeper.supply }),
9115
+ await c.var.admin.publishVersion(c.get('actor'), {
9116
+ id,
9117
+ verticalSlug: slug,
9118
+ version: manifest.version,
9119
+ manifestDigest: manifest.digests.manifest,
9120
+ permissionDigest: manifest.digests.permission,
9121
+ migrationDigest: manifest.digests.migration,
9122
+ deploymentRef,
9123
+ ...(origin ? { origin } : {}),
9124
+ // Retained for the serving upload (#286): the archive script keeps the module
9125
+ // bytes, this keeps their shape (entry, compat, doClasses, bindings).
9126
+ // #1902: with the sweeper decision the push made, which no later upload re-makes.
9127
+ manifestJson: JSON.stringify({ ...manifest, platformSweeper: sweeper.supply }),
9128
+ });
9129
+ return { wire };
9020
9130
  });
9131
+ if (pushed instanceof Response)
9132
+ return pushed;
9133
+ const { wire } = pushed;
9021
9134
  await recordWireUpload(c.var.admin, c.get('actor'), id, wire);
9022
9135
  if (migrationsExtracted)
9023
9136
  await c.var.admin.recordMigrationsExtracted?.(c.get('actor'), slug, id, migrationsExtracted);
@@ -9305,7 +9418,7 @@ export function createControlPlaneApi(options) {
9305
9418
  limit: c.req.query('limit'),
9306
9419
  });
9307
9420
  // #1877: scoped to the app's own scripts — the scan and trust boundary.
9308
- const services = await appServices(c.get('actor'), input.tenantId, input.scopeId, input.vertical);
9421
+ const services = await appServices(c.var.admin, c.get('actor'), input.tenantId, input.scopeId, input.vertical);
9309
9422
  const { operation, principalKind, ...rest } = input;
9310
9423
  const { events, incomplete } = await options.observability.tenantLogs({
9311
9424
  ...rest,
@@ -9342,7 +9455,7 @@ export function createControlPlaneApi(options) {
9342
9455
  catch (e) {
9343
9456
  throw new ControlPlaneError(400, e.message);
9344
9457
  }
9345
- const scopes = await host.admin.listScopes(c.get('actor'), { tenantId: input.tenantId });
9458
+ const scopes = await c.var.admin.listScopes(c.get('actor'), { tenantId: input.tenantId });
9346
9459
  const installed = scopes.find((s) => s.id === input.scopeId);
9347
9460
  if (!installed?.vertical)
9348
9461
  return refuse(c, 'not_found', 'app not found');
@@ -9382,7 +9495,7 @@ export function createControlPlaneApi(options) {
9382
9495
  * nothing. `listScopes`, not `getScopeRecord`: a dashboard read must not write an access
9383
9496
  * row per page view.
9384
9497
  */
9385
- const appServices = async (actor, tenantId, scopeId, vertical) => scriptFamiliesOfScopes(await host.admin.listScopes(actor, { tenantId: tenantIdSchema.parse(tenantId) }), { scopeId, vertical });
9498
+ const appServices = async (admin, actor, tenantId, scopeId, vertical) => scriptFamiliesOfScopes(await admin.listScopes(actor, { tenantId: tenantIdSchema.parse(tenantId) }), { scopeId, vertical });
9386
9499
  app.get('/observability/tenant-request-volume', async (c) => {
9387
9500
  if (!options.observability?.tenantRequestVolume) {
9388
9501
  return refuseStatus(c, 501, 'request aggregation is not configured on this control plane');
@@ -9392,7 +9505,7 @@ export function createControlPlaneApi(options) {
9392
9505
  // A bucket COUNT: zooming in keeps it and narrows the window, so the bucket shrinks.
9393
9506
  // 90 is the design's histogram; the bounds keep a chart drawable and a query cheap.
9394
9507
  const buckets = z.coerce.number().int().min(10).max(240).default(90).parse(c.req.query('buckets'));
9395
- const services = await appServices(c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical);
9508
+ const services = await appServices(c.var.admin, c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical);
9396
9509
  return c.json(await options.observability.tenantRequestVolume({ ...scope, services, buckets }));
9397
9510
  });
9398
9511
  app.get('/observability/tenant-request-facets', async (c) => {
@@ -9405,7 +9518,7 @@ export function createControlPlaneApi(options) {
9405
9518
  // that shows three asks for three.
9406
9519
  const asked = (c.req.queries('facet') ?? []).filter((k) => k.length > 0);
9407
9520
  const keys = asked.length === 0 ? undefined : z.array(z.enum(REQUEST_FACET_KEYS)).parse([...new Set(asked)]);
9408
- const services = await appServices(c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical);
9521
+ const services = await appServices(c.var.admin, c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical);
9409
9522
  return c.json(await options.observability.tenantRequestFacets({ ...scope, services, ...(keys ? { keys } : {}) }));
9410
9523
  });
9411
9524
  // #1747: the tenant's `ctx.log` lines grouped by the template they were written from.
@@ -9434,7 +9547,7 @@ export function createControlPlaneApi(options) {
9434
9547
  buckets: narrow.buckets,
9435
9548
  ...(narrow.level.length ? { level: narrow.level } : {}),
9436
9549
  ...(narrow.operation.length ? { operation: narrow.operation } : {}),
9437
- services: await appServices(c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical),
9550
+ services: await appServices(c.var.admin, c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical),
9438
9551
  }));
9439
9552
  });
9440
9553
  // #2762: the Lines histogram and facets — a tenant's request lines and `ctx.log` lines, counted
@@ -9445,7 +9558,7 @@ export function createControlPlaneApi(options) {
9445
9558
  }
9446
9559
  const scope = parseTenantLineQuery(requestTenantOf(c), requestQueryOf(c));
9447
9560
  const buckets = z.coerce.number().int().min(10).max(240).default(90).parse(c.req.query('buckets'));
9448
- const services = await appServices(c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical);
9561
+ const services = await appServices(c.var.admin, c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical);
9449
9562
  return c.json(await options.observability.tenantLogVolume({ ...scope, services, buckets }));
9450
9563
  });
9451
9564
  app.get('/observability/tenant-log-facets', async (c) => {
@@ -9453,7 +9566,7 @@ export function createControlPlaneApi(options) {
9453
9566
  return refuseStatus(c, 501, 'line aggregation is not configured on this control plane');
9454
9567
  }
9455
9568
  const scope = parseTenantLineQuery(requestTenantOf(c), requestQueryOf(c));
9456
- const services = await appServices(c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical);
9569
+ const services = await appServices(c.var.admin, c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical);
9457
9570
  return c.json(await options.observability.tenantLogFacets({ ...scope, services }));
9458
9571
  });
9459
9572
  app.get('/observability/tenant-requests', async (c) => {
@@ -9462,7 +9575,7 @@ export function createControlPlaneApi(options) {
9462
9575
  }
9463
9576
  const scope = parseTenantRequestQuery(requestTenantOf(c), requestQueryOf(c));
9464
9577
  const limit = z.coerce.number().int().min(1).max(200).default(50).parse(c.req.query('limit'));
9465
- const services = await appServices(c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical);
9578
+ const services = await appServices(c.var.admin, c.get('actor'), scope.tenantId, scope.scopeId, scope.vertical);
9466
9579
  return c.json(await options.observability.tenantRequests({ ...scope, services, limit }));
9467
9580
  });
9468
9581
  /**
@@ -9921,7 +10034,7 @@ export function createControlPlaneApi(options) {
9921
10034
  if (!parsed)
9922
10035
  throw new ControlPlaneError(400, `'${baseHostname}' is not a hostname a preview can be minted beside`);
9923
10036
  const hostname = withLabel(parsed, `${parsed.label}${RESERVED_LABEL_SEPARATOR}${tag}`);
9924
- const existing = (await host.admin.listHostnames(actor, { scopeId: previewId })).find((h) => h.hostname === hostname);
10037
+ const existing = (await admin.listHostnames(actor, { scopeId: previewId })).find((h) => h.hostname === hostname);
9925
10038
  if (!existing) {
9926
10039
  await admin.bindHostname(actor, {
9927
10040
  hostname,
@@ -9932,7 +10045,7 @@ export function createControlPlaneApi(options) {
9932
10045
  canonical: false,
9933
10046
  });
9934
10047
  }
9935
- const bound = (await host.admin.listHostnames(actor, { scopeId: previewId })).find((h) => h.hostname === hostname);
10048
+ const bound = (await admin.listHostnames(actor, { scopeId: previewId })).find((h) => h.hostname === hostname);
9936
10049
  if (bound)
9937
10050
  await runIssuance(admin, actor, bound); // platform mint rides the wildcard cert → active
9938
10051
  return hostname;
@@ -9941,9 +10054,9 @@ export function createControlPlaneApi(options) {
9941
10054
  * source scope's canonical URL; a clean-room preview (no source, #509 ask (b)) has none,
9942
10055
  * so it is built from the platform tenant-app convention `<vertical>-<tenant>.<base>` —
9943
10056
  * the same scheme provisioning mints (`callout-sesamy.global.substrat.run`). */
9944
- const previewBaseHostname = async (actor, source, tenantId, slug, surface) => {
10057
+ const previewBaseHostname = async (admin, actor, source, tenantId, slug, surface) => {
9945
10058
  if (source) {
9946
- const own = await host.admin.listHostnames(actor, { scopeId: source.id });
10059
+ const own = await admin.listHostnames(actor, { scopeId: source.id });
9947
10060
  const src = own.find((h) => h.canonical && h.surface === surface) ??
9948
10061
  own.find((h) => h.canonical) ??
9949
10062
  own[0];
@@ -9965,7 +10078,7 @@ export function createControlPlaneApi(options) {
9965
10078
  // bare apex and stranded clean-room previews on a hostname that never resolves.
9966
10079
  const baseDomain = [...platformBaseDomains].sort((a, b) => a.length - b.length)[0];
9967
10080
  const jurisdiction = 'global';
9968
- const tenant = await host.admin.getTenant(actor, tenantId);
10081
+ const tenant = await admin.getTenant(actor, tenantId);
9969
10082
  const handle = tenant?.slug ?? tenantId;
9970
10083
  return `${slug.split('/').at(-1)}-${handle}.${jurisdiction}.${baseDomain}`;
9971
10084
  };
@@ -10203,7 +10316,7 @@ export function createControlPlaneApi(options) {
10203
10316
  : [];
10204
10317
  // The `--<tag>` URL's base — the source's canonical hostname (fork) or the tenant-app
10205
10318
  // convention (clean-room). Computed once so reuse and fresh mint the same URL.
10206
- const baseHostname = await previewBaseHostname(actor, source, tenantId, slug, surface);
10319
+ const baseHostname = await previewBaseHostname(c.var.admin, actor, source, tenantId, slug, surface);
10207
10320
  // The founding #559 case lands its durable row HERE, not in onError: the previews
10208
10321
  // route answers a ControlPlaneError directly (its own catch, never the app-level
10209
10322
  // recorder), and only this frame knows the preview's scopeId (`intoId`) — the key that lets
@@ -10769,7 +10882,7 @@ export function createControlPlaneApi(options) {
10769
10882
  const previewVertical = async (c) => {
10770
10883
  const p = c.get('principal');
10771
10884
  const slug = await resolveVerticalId(c, c.req.param('slug'));
10772
- const v = await verticalOf(c.get('actor'), slug);
10885
+ const v = await verticalOf(c.var.admin, c.get('actor'), slug);
10773
10886
  if (!v)
10774
10887
  return { refused: 'not_found', error: `unknown vertical '${slug}'` };
10775
10888
  if (confinedTenant(p) !== null && v.ownerTenant !== confinedTenant(p)) {
@@ -11530,6 +11643,32 @@ export function createControlPlaneApi(options) {
11530
11643
  const verticals = [...new Set([...declared.verticals.map((v) => v.vertical), ...declared.unreadable])].sort();
11531
11644
  return { fingerprint, ...storeRebuildStatus(walk, fingerprint, verticals, options.storeReleases?.settleMs), walk: walk ?? null };
11532
11645
  };
11646
+ // The scope-store registry a push is judged by, for ONE vertical (#2813): `?vertical=` is the slug the
11647
+ // push deploys as, required (bare, or already prefixed with the caller's own workspace). Every registration comes back, with `verticals` reduced to
11648
+ // `[vertical]` when it serves that vertical and `[]` when it does not (omitted when it serves
11649
+ // every one), so the CLI's own check can say "does not serve" and no other vertical's slug is
11650
+ // read. "Serves" is judged for the registry id the deploy forms (`<tenant>/<name>` for a builder
11651
+ // or a pinned staff push), the same one `ensureScopeStoresOf` judges. The staff-only
11652
+ // `/store-drivers/:driver` below is the driver's release state and stays as it was.
11653
+ app.get('/store-drivers', async (c) => {
11654
+ const parsed = verticalSlug.safeParse(c.req.query('vertical'));
11655
+ if (!parsed.success)
11656
+ return refuse(c, 'validation_failed', 'vertical is required: the slug the push deploys as');
11657
+ const vertical = parsed.data;
11658
+ const p = c.get('principal');
11659
+ const pin = c.req.header(TENANT_HEADER)?.trim();
11660
+ let id = effectiveSlug(p, vertical);
11661
+ if (p.kind !== 'builder' && pin) {
11662
+ // The pinned-staff branch of the deploy's own slug derivation (`POST /verticals/:slug/deploy`).
11663
+ const workspace = (await c.var.admin.listTenants(c.get('actor'))).find((t) => t.slug === pin || t.id === pin);
11664
+ if (!workspace)
11665
+ return refuse(c, 'not_found', `unknown workspace '${pin}'`);
11666
+ const bareOwner = await ownerOf(c.var.admin, c.get('actor'), vertical);
11667
+ id = vertical.startsWith(`${workspace.slug}/`) || bareOwner === workspace.id ? vertical : `${workspace.slug}/${vertical}`;
11668
+ }
11669
+ const drivers = (options.storeDrivers ?? STORE_DRIVERS).map((d) => d.verticals ? { ...d, verticals: storeDriverServesVertical(d, id) ? [vertical] : [] } : d);
11670
+ return c.json({ drivers });
11671
+ });
11533
11672
  app.get('/store-drivers/:driver', async (c) => {
11534
11673
  await staffPerson(c, statusWords);
11535
11674
  const registration = registeredDriver(c.req.param('driver'));