@substrat-run/control-plane-api 0.176.0 → 0.178.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 (82) hide show
  1. package/dist/api.d.ts +12 -0
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +612 -210
  4. package/dist/api.js.map +1 -1
  5. package/dist/backups.d.ts +80 -16
  6. package/dist/backups.d.ts.map +1 -1
  7. package/dist/backups.js +3 -0
  8. package/dist/backups.js.map +1 -1
  9. package/dist/browser.d.ts +1 -1
  10. package/dist/browser.d.ts.map +1 -1
  11. package/dist/browser.js.map +1 -1
  12. package/dist/builder-turns.d.ts +16 -0
  13. package/dist/builder-turns.d.ts.map +1 -0
  14. package/dist/builder-turns.js +43 -0
  15. package/dist/builder-turns.js.map +1 -0
  16. package/dist/connection-binding-relay.d.ts +28 -1
  17. package/dist/connection-binding-relay.d.ts.map +1 -1
  18. package/dist/connection-binding-relay.js +87 -2
  19. package/dist/connection-binding-relay.js.map +1 -1
  20. package/dist/connection-relay.d.ts +2 -2
  21. package/dist/connection-relay.d.ts.map +1 -1
  22. package/dist/connection-relay.js.map +1 -1
  23. package/dist/directory-backup.d.ts +4 -0
  24. package/dist/directory-backup.d.ts.map +1 -1
  25. package/dist/directory-backup.js +60 -7
  26. package/dist/directory-backup.js.map +1 -1
  27. package/dist/index.d.ts +4 -2
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +3 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/lake-gateway.d.ts +9 -0
  32. package/dist/lake-gateway.d.ts.map +1 -1
  33. package/dist/lake-gateway.js +18 -1
  34. package/dist/lake-gateway.js.map +1 -1
  35. package/dist/migration-extract.d.ts +92 -0
  36. package/dist/migration-extract.d.ts.map +1 -0
  37. package/dist/migration-extract.js +217 -0
  38. package/dist/migration-extract.js.map +1 -0
  39. package/dist/platform-entry.d.ts +2 -0
  40. package/dist/platform-entry.d.ts.map +1 -1
  41. package/dist/platform-entry.generated.d.ts +1 -1
  42. package/dist/platform-entry.generated.d.ts.map +1 -1
  43. package/dist/platform-entry.generated.js +2 -2
  44. package/dist/platform-entry.generated.js.map +1 -1
  45. package/dist/platform-entry.js +2 -1
  46. package/dist/platform-entry.js.map +1 -1
  47. package/dist/platform-scope-stores.d.ts +10 -12
  48. package/dist/platform-scope-stores.d.ts.map +1 -1
  49. package/dist/platform-scope-stores.js +47 -24
  50. package/dist/platform-scope-stores.js.map +1 -1
  51. package/dist/r2-backups.d.ts +8 -5
  52. package/dist/r2-backups.d.ts.map +1 -1
  53. package/dist/r2-backups.js +232 -56
  54. package/dist/r2-backups.js.map +1 -1
  55. package/dist/seal.d.ts +5 -2
  56. package/dist/seal.d.ts.map +1 -1
  57. package/dist/seal.js +3 -0
  58. package/dist/seal.js.map +1 -1
  59. package/dist/staff-client.d.ts +10 -1
  60. package/dist/staff-client.d.ts.map +1 -1
  61. package/dist/staff-client.js +4 -2
  62. package/dist/staff-client.js.map +1 -1
  63. package/dist/store-entry.d.ts.map +1 -1
  64. package/dist/store-entry.generated.d.ts +2 -7
  65. package/dist/store-entry.generated.d.ts.map +1 -1
  66. package/dist/store-entry.generated.js +5 -10
  67. package/dist/store-entry.generated.js.map +1 -1
  68. package/dist/store-entry.js +3 -2
  69. package/dist/store-entry.js.map +1 -1
  70. package/dist/store-releases.d.ts +18 -4
  71. package/dist/store-releases.d.ts.map +1 -1
  72. package/dist/store-releases.js +74 -11
  73. package/dist/store-releases.js.map +1 -1
  74. package/dist/store-script.d.ts +12 -2
  75. package/dist/store-script.d.ts.map +1 -1
  76. package/dist/store-script.js +47 -9
  77. package/dist/store-script.js.map +1 -1
  78. package/dist/vertical-client.d.ts +6 -0
  79. package/dist/vertical-client.d.ts.map +1 -1
  80. package/dist/vertical-client.js +16 -0
  81. package/dist/vertical-client.js.map +1 -1
  82. package/package.json +9 -9
package/dist/api.js CHANGED
@@ -3,7 +3,7 @@ import { PREVIEW_CONNECTIONS_REFUSAL } from './connection-relay.js';
3
3
  import { bindingChangeRefusal, bindingOptionsRefusal, connectionBindingOptions, refusesBindingChange } from './connection-binding-options.js';
4
4
  import { resolveObservabilityWindow } from './observability-window.js';
5
5
  import { Hono } from 'hono';
6
- import { permissionKey, parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, lakeEventsInput, ownerTransferInput, memberInviteInput, memberRoleInput, ASSET_PART_PREFIX, assetHash, STORE_DRIVERS, storeCatalogOperation, storeDriverBasisAdmits, channelName, connectLinkId, connectionActivity, connectionActivitySource, connectionCredential, connectionCertificate, connectionFilter, connectionHealthState, connectionProvider, connectorDispatchKind, PROVISION_SIBLING_KIND, ARCHIVE_SCOPE_KIND, PROVISION_TENANT_KIND, SET_ENTITLEMENTS_KIND, MODEL_USAGE_KIND, SWEEP_RUNS_KIND, toConnectionHealthEntry, CONNECTION_EXPIRY_WARNING_DAYS, CONNECTION_STALE_DAYS, connectionProbe, connectionSendTest, connectionSendTestResult, connectionSenders, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, errorCode, issueStatus, issueStatusInput, FINDINGS_LIMIT_DEFAULT, FINDINGS_LIMIT_MAX, findingCursorOf, findingKind, findingRuleInput, findingStatus, findingStatusInput, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, impersonationStamp, promotionAcknowledgement, bindAcknowledgement, rebindAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, lifecycleFlowInput, operationSeriesCount, operationSeriesInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, peerSwitch, verticalSlug, scopeText, systemSwitch, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, denialGroupBy, registerVerticalInput, platformScriptCollision, platformScriptRefusal, PLATFORM_SCRIPT_NAMES, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, requestRollupReading, meterDaysReading, verticalOwnersReading, utcDay, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, validationIssuesFrom, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, substratError, errorCodeOf, envVarSpec, capabilityFilterQuery, capabilityId as capabilityIdSchema, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, PLATFORM_FEATURE_SOURCE_BUILDS, SOCIAL_RELAY_VERTICALS, isVerticalConnectorProvider, } from '@substrat-run/contracts';
6
+ import { permissionKey, parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, lakeEventsInput, ownerTransferInput, memberInviteInput, memberRoleInput, ASSET_PART_PREFIX, assetHash, STORE_DRIVERS, storeDriverErasesSubjects, storeDriverServesVertical, storeCatalogOperation, storeDriverBasisAdmits, channelName, connectLinkId, connectionActivity, connectionActivitySource, connectionCredential, connectionCertificate, connectionFilter, connectionHealthState, connectionProvider, connectorDispatchKind, PROVISION_SIBLING_KIND, ARCHIVE_SCOPE_KIND, PROVISION_TENANT_KIND, SET_ENTITLEMENTS_KIND, MODEL_USAGE_KIND, SWEEP_RUNS_KIND, toConnectionHealthEntry, CONNECTION_EXPIRY_WARNING_DAYS, CONNECTION_STALE_DAYS, connectionProbe, connectionSendTest, connectionSendTestResult, connectionSenders, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, errorCode, issueStatus, issueStatusInput, FINDINGS_LIMIT_DEFAULT, FINDINGS_LIMIT_MAX, findingCursorOf, findingKind, findingRuleInput, findingStatus, findingStatusInput, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, impersonationStamp, promotionAcknowledgement, bindAcknowledgement, rebindAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, lifecycleFlowInput, operationSeriesCount, operationSeriesInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, peerSwitch, verticalSlug, scopeText, systemSwitch, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, denialGroupBy, registerVerticalInput, platformScriptCollision, platformScriptRefusal, PLATFORM_SCRIPT_NAMES, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, mergeDumpPages, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, requestRollupReading, meterDaysReading, verticalOwnersReading, utcDay, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, validationIssuesFrom, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, substratError, errorCodeOf, envVarSpec, capabilityFilterQuery, capabilityId as capabilityIdSchema, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, PLATFORM_FEATURE_SOURCE_BUILDS, SOCIAL_RELAY_VERTICALS, isVerticalConnectorProvider, } from '@substrat-run/contracts';
7
7
  import { upstreamHandleIn, upstreamReferenceOf, withUpstreamReference } from '@substrat-run/contracts';
8
8
  import { ENGINE_ENTITLEMENT_KEYS, bareSlug, declarableEntitlementKey, entitlementNamespaceRefusal, grantableEntitlements } from '@substrat-run/contracts';
9
9
  import { SCOPE_COPY_LEASE_MS, copyRestoreFence, publishedMigrationDigest, replayStoreErasures } from '@substrat-run/kernel';
@@ -70,11 +70,12 @@ import { parseTenantRequestQuery, parseTenantWindowQuery, refineTenantWindow, re
70
70
  import { namespacesForScript } from './do-namespaces.js';
71
71
  import { backupDirectoryIfDue } from './directory-backup.js';
72
72
  import { deployedDoClasses, platformSweeperDecision } from './platform-entry.js';
73
+ import { extractedMigrationsDigest, extractedMigrationsProblem, extractedMigrationsRefusal, MigrationExtractionError, MigrationLoaderUnavailable, } from './migration-extract.js';
73
74
  import { wireSettingsRefusal, wireUploadFor } from './wire-upload.js';
74
75
  import { parseWireWalkCursor, runWireWalk, WIRE_WALK_LIMIT_DEFAULT, WIRE_WALK_LIMIT_MAX, WIRE_WALK_RELEASE_MAX, } from './wire-walk.js';
75
76
  import { wireKeyDropSince, wireReadinessReport, WIRE_READINESS_LIMIT_DEFAULT, WIRE_READINESS_LIMIT_MAX } from './wire-readiness.js';
76
77
  import { STORAGE_PAGE_DEFAULT, STORAGE_PAGE_MAX, readStoragePage } from './storage-meter.js';
77
- import { queryLakeEvents } from './lake-gateway.js';
78
+ import { queryLakeEvents, teamWindowRefusal } from './lake-gateway.js';
78
79
  import { LakeQueryError } from './r2-sql-engine.js';
79
80
  import { isCustomHostname, validateBindableHostname, } from './custom-hostnames.js';
80
81
  import { isSocialRelayVertical, reconcileScopeSocialRelay, relayHostnames, } from './social-relay.js';
@@ -441,13 +442,62 @@ const restoreDirectoryBody = z.object({
441
442
  overwrite: z.boolean().optional(),
442
443
  });
443
444
  /**
444
- * How a stored backup is named in the admin log and to callers: the route that fetches
445
- * it. Store-neutral by construction — the R2 key scheme stays private to the store — and
446
- * an operator reading a reap entry gets an address they can actually GET, rather than a
447
- * bucket path they would have to know the platform's internals to use.
445
+ * How a stored backup is named in the admin log and to callers: the route that restores
446
+ * it (#2839), which every backup can take, whereas only one stored in a single entry can be
447
+ * read whole. Store-neutral by construction — the R2 key scheme stays private to the store —
448
+ * so an operator reading a reap entry gets an address they can use, rather than a bucket
449
+ * path they would have to know the platform's internals to use.
448
450
  */
449
451
  function backupRefOf(b) {
450
- return `/tenants/${b.tenantId}/scopes/${b.scopeId}/backups/${b.capturedAt}`;
452
+ return `/tenants/${b.tenantId}/scopes/${b.scopeId}/backups/${b.capturedAt}/restore`;
453
+ }
454
+ /** The tables whose rows a host judges a load's roles by (#2555): the role definitions and their seats. */
455
+ const ROLES_TABLE = '_substrat_roles';
456
+ const TUPLES_TABLE = '_substrat_tuples';
457
+ /**
458
+ * What a host judges a backup restored from its entries by (#2839): every table's schema, with the
459
+ * rows of the role definitions and of the role seats only, as the entries leave them in order (a
460
+ * change batch replaces a row by its rowid, deletes by rowid, or replaces a table read whole). The
461
+ * scope's data stays at the deployment; this holds no more than the roles a load would apply.
462
+ */
463
+ function rolesJudgedOf() {
464
+ const schemas = new Map();
465
+ const rows = new Map();
466
+ let unkeyed = 0;
467
+ const judged = (t, row) => t.name === ROLES_TABLE || (t.name === TUPLES_TABLE && String(row[t.columns.indexOf('relation')]).startsWith('role:'));
468
+ const take = (t, replace) => {
469
+ if (t.name !== ROLES_TABLE && t.name !== TUPLES_TABLE)
470
+ return;
471
+ const held = replace ? new Map() : (rows.get(t.name) ?? new Map());
472
+ t.rows.forEach((row, i) => {
473
+ const key = t.rowids?.[i] ?? `u${unkeyed++}`;
474
+ if (judged(t, row))
475
+ held.set(key, row);
476
+ else
477
+ held.delete(key);
478
+ });
479
+ rows.set(t.name, held);
480
+ };
481
+ return {
482
+ page(tables) {
483
+ for (const { rows: _, rowids: __, ...schema } of tables) {
484
+ if (!schemas.has(schema.name))
485
+ schemas.set(schema.name, { ...schema, rows: [] });
486
+ }
487
+ for (const t of tables)
488
+ take(t, false);
489
+ },
490
+ changes(tables) {
491
+ for (const { deleted, ...t } of tables) {
492
+ for (const id of deleted)
493
+ rows.get(t.name)?.delete(id);
494
+ take(t, !t.rowids);
495
+ }
496
+ },
497
+ tables() {
498
+ return [...schemas.values()].map((t) => ({ ...t, rows: [...(rows.get(t.name)?.values() ?? [])] }));
499
+ },
500
+ };
451
501
  }
452
502
  // A per-PR preview request (preview-and-snapshots.md §2/§9 — the "run a new version
453
503
  // against a fork of prod" slice). `tag` is a short DNS-safe label (`pr-123`): it names
@@ -730,6 +780,8 @@ const TENANT_ROUTES = [
730
780
  // grant (`tenantGrantableEntitlement`): the pin alone would let it grant itself any.
731
781
  { method: 'PUT', re: /\/tenants\/[^/]+\/entitlements\/[^/]+$/, pin: 'path' },
732
782
  { method: 'GET', re: /\/tenants\/[^/]+\/cross-vertical\/edges$/, pin: 'path' },
783
+ // #1334: the team's own lake events. The handler adds the window rule for this credential.
784
+ { method: 'GET', re: /\/tenants\/[^/]+\/lake\/events$/, pin: 'path' },
733
785
  // Findings (#1748): the tenant's inbox, its verdicts and its suppress rules. The dashboard
734
786
  // checks WHO inside the tenant may read or act (its findings permissions) before calling.
735
787
  { method: 'PUT', re: /\/tenants\/[^/]+\/findings\/[^/]+\/status$/, pin: 'path' },
@@ -964,7 +1016,7 @@ export function createControlPlaneApi(options) {
964
1016
  /** `substrat.wipe` on each declared store, before the scope's own bytes go. */
965
1017
  const wipePlatformStores = async (c, scope) => (await platformStoresWipe(c, scope))();
966
1018
  /**
967
- * `substrat.export` on each store the lifecycle reaches, for a backup, a full pull or a fork.
1019
+ * A dump in pages (`substrat.export-page`) of each store the lifecycle reaches, for a backup, a full pull or a fork.
968
1020
  *
969
1021
  * A dump is only worth taking if it restores, and a restore imports only what the destination's
970
1022
  * version declares (L8). So a binding only an earlier version declared (#2286) is left out of the
@@ -1249,6 +1301,9 @@ export function createControlPlaneApi(options) {
1249
1301
  { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/model$/ },
1250
1302
  { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/concept$/ },
1251
1303
  { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/schedules$/ },
1304
+ // The scope-store drivers one vertical may use (#2813), so a push judges its stores as the
1305
+ // upload will. `?vertical=` required; the answer names no other vertical.
1306
+ { method: 'GET', re: /\/store-drivers$/ },
1252
1307
  { method: 'GET', re: /\/verticals\/[^/]+\/channels$/ },
1253
1308
  { method: 'GET', re: /\/verticals\/[^/]+\/channels\/[^/]+\/history$/ },
1254
1309
  { method: 'POST', re: /\/verticals\/[^/]+\/channels\/[^/]+\/promote$/ },
@@ -5346,20 +5401,67 @@ export function createControlPlaneApi(options) {
5346
5401
  `store is global, so a backup would move its data out of that jurisdiction ` +
5347
5402
  `(K-32); refused until a per-jurisdiction store exists`);
5348
5403
  }
5349
- const dump = await c.var.admin.exportScope(c.get('actor'), tenantId, scope.id);
5404
+ const { tables: hostTables, ...header } = await c.var.admin.exportScope(c.get('actor'), tenantId, scope.id);
5350
5405
  const vertical = await verticalForScope(c, scope);
5351
- const tables = vertical ? await vertical.exportScope(scope.id) : dump.tables;
5352
5406
  // Store tables are opaque and stay unsealed. The platform reads them from the store script
5353
5407
  // (#2207); a store it cannot name or reach refuses the backup rather than omitting it.
5354
- const stores = (await exportPlatformStores(c, scope)) ?? dump.stores;
5355
- // Seal classified payloads per subject on the way in (#37). This copy is full-fidelity
5356
- // and the platform keeps it, which is precisely why an erasure could never reach it: a
5357
- // redaction fixes the live scope and does nothing to the object already in R2. Sealed
5358
- // here, destroying one subject's key reaches backwards into every copy already taken.
5359
- // Restores open it again (`restoreFromBackup`) — except for subjects shredded in the
5360
- // meantime, which is the mechanism working rather than the copy being damaged.
5361
- const sealed = await sealDump(tables, sealerFor(c, tenantId, scope.id));
5362
- return store.put({ vertical: scope.vertical, dump: { ...dump, tables: sealed, stores } });
5408
+ const stores = (await exportPlatformStores(c, scope)) ?? header.stores;
5409
+ const backup = backupStage(store.begin({ vertical: scope.vertical, header: { ...header, stores } }), sealerFor(c, tenantId, scope.id));
5410
+ // #2839: from a vertical, the data moves into the store a page at a time, as a paged copy
5411
+ // moves it into another scope (#2450): captured, so a scope taking writes meanwhile is still
5412
+ // copied as it was at one moment, and never held whole here. A deployment that predates
5413
+ // paging answers whole, and that is stored as one entry, as the co-located host's tables are.
5414
+ const moved = vertical
5415
+ ? (await transferScope(vertical, backup.dest, { from: scope.id }, { retry: retryTransient, stamp: false, alwaysStage: true })).dump
5416
+ : hostTables;
5417
+ if (Array.isArray(moved))
5418
+ await backup.dest.stageScopePage(scope.id, ulid(), 0, moved);
5419
+ try {
5420
+ return await backup.commit();
5421
+ }
5422
+ catch (e) {
5423
+ await backup.discard();
5424
+ throw e;
5425
+ }
5426
+ };
5427
+ /**
5428
+ * A backup being written, as the destination of a paged copy (#2839): each page and each batch of
5429
+ * captured changes the copy stages becomes one entry, sealed per subject on the way in (#37).
5430
+ * This copy is full-fidelity and the platform keeps it, which is precisely why an erasure could
5431
+ * never reach it: a redaction fixes the live scope and does nothing to the object already in R2.
5432
+ * Sealed here, destroying one subject's key reaches backwards into every copy already taken.
5433
+ * Restores open it again, except for subjects shredded in the meantime, which is the mechanism
5434
+ * working rather than the copy being damaged.
5435
+ *
5436
+ * A copy that starts again discards its stage and stages anew, so the backup is the stage written
5437
+ * last.
5438
+ */
5439
+ const backupStage = (writer, sealer) => {
5440
+ let last = { stageId: '', entries: 0, tables: new Set() };
5441
+ const took = (stageId, seq, tables) => {
5442
+ if (stageId !== last.stageId)
5443
+ last = { stageId, entries: 0, tables: new Set() };
5444
+ last.entries = Math.max(last.entries, seq + 1);
5445
+ for (const t of tables)
5446
+ last.tables.add(t.name);
5447
+ };
5448
+ return {
5449
+ dest: {
5450
+ stageScopePage: async (_, stageId, seq, tables) => {
5451
+ await writer.put(stageId, seq, { kind: 'page', tables: await sealDump(tables, sealer) });
5452
+ took(stageId, seq, tables);
5453
+ return { staged: true };
5454
+ },
5455
+ stageScopeChanges: async (_, stageId, seq, tables) => {
5456
+ await writer.put(stageId, seq, { kind: 'changes', tables: await sealDump(tables, sealer) });
5457
+ took(stageId, seq, tables);
5458
+ return { staged: true };
5459
+ },
5460
+ discardStagedScope: (_, stageId) => writer.discard(stageId),
5461
+ },
5462
+ commit: () => writer.commit({ stageId: last.stageId, entries: last.entries, tables: last.tables.size }),
5463
+ discard: () => writer.discard(last.stageId),
5464
+ };
5363
5465
  };
5364
5466
  // The copies held for one scope — metadata only, so listing a reaped scope's backups
5365
5467
  // is cheap and hands out no bytes. Readable AFTER the reap (that is the point): the
@@ -5377,9 +5479,11 @@ export function createControlPlaneApi(options) {
5377
5479
  return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5378
5480
  return c.json(await options.scopeBackups.list({ tenantId, scopeId }));
5379
5481
  });
5380
- // One backup's DUMP — the restore source. Staff-only like the export it came from
5381
- // (not in BUILDER_ROUTES), and full-fidelity, so it is the same governed-pull posture:
5382
- // K-3 cross-checked above, and `POST …/restore` is where it goes back.
5482
+ // One backup's DUMP. Staff-only like the export it came from (not in BUILDER_ROUTES), and
5483
+ // full-fidelity, so it is the same governed-pull posture: K-3 cross-checked above. Only a
5484
+ // backup stored in one entry is served whole (#2839). One stored in several (more pages, or
5485
+ // writes caught while it was taken) is not a dump until a deployment applies them in order,
5486
+ // so it is answered 413 and `POST …/backups/:capturedAt/restore` loads it back.
5383
5487
  app.get('/tenants/:tenantId/scopes/:scopeId/backups/:capturedAt', async (c) => {
5384
5488
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
5385
5489
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
@@ -5389,16 +5493,20 @@ export function createControlPlaneApi(options) {
5389
5493
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
5390
5494
  if (!scope)
5391
5495
  return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5392
- const dump = await options.scopeBackups.get({ tenantId, scopeId, capturedAt });
5393
- if (!dump)
5496
+ const held = await options.scopeBackups.get({ tenantId, scopeId, capturedAt });
5497
+ if (!held)
5394
5498
  return refuse(c, 'not_found', `no backup for scope ${scopeId} at ${capturedAt}`);
5499
+ if (held.entries > 1) {
5500
+ 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`);
5501
+ }
5502
+ const { tables } = await held.entry(0);
5395
5503
  // Unseal on the way out (#37). The bytes in the store stay sealed — this is the
5396
5504
  // authorized read opening them with the keys the platform holds. A subject shredded
5397
5505
  // since the copy was taken opens to a null payload, which is exactly the erasure
5398
5506
  // working: the ciphertext never left the object, and nothing turns it back into a
5399
5507
  // person. A dump taken before sealing existed passes through untouched.
5400
- const opened = await openDump(dump.tables, sealerFor(c, tenantId, scopeId));
5401
- return c.json({ ...dump, tables: opened });
5508
+ const opened = await openDump(mergeDumpPages([tables]), sealerFor(c, tenantId, scopeId));
5509
+ return c.json({ ...held.header, tables: opened });
5402
5510
  });
5403
5511
  // Take a backup WITHOUT reaping — the standalone copy (a pre-migration checkpoint, an
5404
5512
  // export-to-keep). The reap route below takes its own; this is the same act made
@@ -5458,6 +5566,15 @@ export function createControlPlaneApi(options) {
5458
5566
  // version that declares none is not asked. One that cannot be resolved while a driver is
5459
5567
  // registered refuses (503): the key must not be destroyed while a store may hold the subject.
5460
5568
  const reach = await platformStoresOf(c, scope, running);
5569
+ // A store whose driver cannot erase a subject yet would answer `{ erased: 0 }` while it still
5570
+ // holds them. Refused before anything is erased, so the key is kept and a retry can converge.
5571
+ const unerasable = reach?.stores.find((store) => {
5572
+ const registration = (options.storeDrivers ?? STORE_DRIVERS).find((k) => k.driver === store.driver);
5573
+ return registration !== undefined && !storeDriverErasesSubjects(registration);
5574
+ });
5575
+ if (unerasable) {
5576
+ throw new ControlPlaneError(501, `scope ${scopeId}'s store '${unerasable.binding}' runs driver '${unerasable.driver}', which cannot erase a subject yet; nothing was erased`);
5577
+ }
5461
5578
  /**
5462
5579
  * L19: a bind, promote, rollback or serving re-upload that lands while the stores are erased
5463
5580
  * would run a version whose stores this erasure did not reach. Both arms take one atomic claim
@@ -5900,9 +6017,10 @@ export function createControlPlaneApi(options) {
5900
6017
  // closed `.strict()` list with no tenant in it, and a row of any other tenant fails the
5901
6018
  // read whole. Envelope only — no payload until erasure reaches the lake.
5902
6019
  //
5903
- // Staff only, by default-deny: on neither BUILDER_ROUTES nor TENANT_ROUTES. A tenant's
5904
- // own credential reaching this is a separate decision, with a dashboard call site and a
5905
- // member permission in front of it.
6020
+ // Staff, and a team's own credential (TENANT_ROUTES, path-pinned). The dashboard checks
6021
+ // `dashboard:read-event-lake` before it calls. A team's read must name a window of at most
6022
+ // `LAKE_TEAM_WINDOW_DAYS` (`teamWindowRefusal`): nothing below the gateway prunes by tenant,
6023
+ // so the window is what bounds the scan. Builders stay refused (not in BUILDER_ROUTES).
5906
6024
  //
5907
6025
  // K-24: the access row is written AFTER the engine answers and BEFORE anything is
5908
6026
  // returned, and a failed write fails the request — rows whose disclosure went unrecorded
@@ -5923,6 +6041,11 @@ export function createControlPlaneApi(options) {
5923
6041
  issues: parsed.error.issues.map((i) => ({ path: i.path, message: i.message })),
5924
6042
  });
5925
6043
  }
6044
+ if (c.get('principal').kind === 'tenant') {
6045
+ const refusal = teamWindowRefusal(parsed.data, Date.now());
6046
+ if (refusal)
6047
+ return refuse(c, 'validation_failed', refusal);
6048
+ }
5926
6049
  let page;
5927
6050
  try {
5928
6051
  page = await queryLakeEvents(options.lakeQuery, tenantId, parsed.data);
@@ -6232,32 +6355,135 @@ export function createControlPlaneApi(options) {
6232
6355
  // keyed by (scope, subject), so a copy of scope A landing in scope B — a backout onto
6233
6356
  // a different scope, a world loaded sideways — must be opened with A's keys. Using
6234
6357
  // the destination's would find no key, null every payload, and call it a restore.
6235
- const origin = { tenantId: tenantIdSchema.parse(dump.tenantId), scopeId: scopeIdSchema.parse(dump.scopeId) };
6236
- const tables = await openDump(dump.tables, sealerFor(c, origin.tenantId, origin.scopeId));
6237
- const vertical = await verticalForScope(c, scope);
6358
+ const tables = await openDump(dump.tables, sealerFor(c, tenantIdSchema.parse(dump.tenantId), scopeIdSchema.parse(dump.scopeId)));
6359
+ return c.json(await loadIntoScope(c, scope, { header: dump, tables, sourceHint }));
6360
+ }
6361
+ catch (e) {
6362
+ return restoreFailed(c, scope, e);
6363
+ }
6364
+ });
6365
+ // Restore a held backup into its scope, read from the store here rather than handed in (#2839):
6366
+ // a backup stored in parts is staged at the deployment one entry at a time, opened with the
6367
+ // backup's own provenance, and loaded from the stage, so no call holds the whole backup. One
6368
+ // stored whole loads as `POST …/restore` loads a dump, through the same path. Staff-only, as the
6369
+ // backup routes are (not in TENANT_ROUTES or BUILDER_ROUTES).
6370
+ app.post('/tenants/:tenantId/scopes/:scopeId/backups/:capturedAt/restore', async (c) => {
6371
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
6372
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
6373
+ const capturedAt = c.req.param('capturedAt');
6374
+ const store = options.scopeBackups;
6375
+ if (!store)
6376
+ return refuseStatus(c, 501, 'no backup target configured');
6377
+ const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
6378
+ if (!scope)
6379
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
6380
+ const held = await store.get({ tenantId, scopeId, capturedAt });
6381
+ if (!held)
6382
+ return refuse(c, 'not_found', `no backup for scope ${scopeId} at ${capturedAt}`);
6383
+ const { header } = held;
6384
+ if (options.platformScopeStores) {
6385
+ try {
6386
+ await refuseUndeclaredDumpStores(c, scope, header.stores);
6387
+ }
6388
+ catch (e) {
6389
+ if (e instanceof ControlPlaneError)
6390
+ return relayRefusal(c, e);
6391
+ throw e;
6392
+ }
6393
+ }
6394
+ const sealer = sealerFor(c, tenantIdSchema.parse(header.tenantId), scopeIdSchema.parse(header.scopeId));
6395
+ try {
6396
+ if (held.entries === 1) {
6397
+ const { tables } = await held.entry(0);
6398
+ if (tables.length === 0) {
6399
+ return refuseStatus(c, 422, 'restore refused: the backup has no tables — not a scope dump');
6400
+ }
6401
+ return c.json(await loadIntoScope(c, scope, { header, tables: await openDump(mergeDumpPages([tables]), sealer) }));
6402
+ }
6403
+ // The host judges the roles the backup seats or defines before anything is replaced (#2555), and
6404
+ // records the tables it restores. So it is handed every table's schema and the role tables' rows,
6405
+ // gathered while the entries are staged: its whole part of a load into a deployment, whose
6406
+ // placeholder is not the scope's store.
6407
+ const stage = async (vertical) => {
6408
+ const stageId = ulid();
6409
+ const judged = rolesJudgedOf();
6410
+ try {
6411
+ for (let index = 0; index < held.entries; index++) {
6412
+ const entry = await held.entry(index);
6413
+ let staged;
6414
+ if (entry.kind === 'page') {
6415
+ const tables = await openDump(entry.tables, sealer);
6416
+ judged.page(tables);
6417
+ staged = await retryTransient(() => vertical.stageScopePage(scopeId, stageId, index, tables));
6418
+ }
6419
+ else {
6420
+ const tables = await openDump(entry.tables, sealer);
6421
+ judged.changes(tables);
6422
+ staged = await retryTransient(() => vertical.stageScopeChanges(scopeId, stageId, index, tables));
6423
+ }
6424
+ if (staged === 'predates') {
6425
+ throw new ControlPlaneError(409, `backup ${capturedAt} is stored in ${held.entries} parts, and the deployment scope ${scopeId} runs cannot load one in parts; push a newer version of the vertical and restore again`);
6426
+ }
6427
+ }
6428
+ }
6429
+ catch (e) {
6430
+ await abandonCopy({ dest: vertical }, { from: scopeId, into: scopeId, stageId });
6431
+ throw e;
6432
+ }
6433
+ return { dump: { stageId }, tables: judged.tables() };
6434
+ };
6435
+ return c.json(await loadIntoScope(c, scope, { header, tables: [], stage }));
6436
+ }
6437
+ catch (e) {
6438
+ return restoreFailed(c, scope, e);
6439
+ }
6440
+ });
6441
+ /**
6442
+ * Load a dump into an existing scope: the one path `POST …/restore` and a backup's restore share,
6443
+ * so both write the same audit row, judge the same roles and stores, replay the same erasures and
6444
+ * clear the same receipt. `tables` are opened (#37). `stage`, for a backup stored in several
6445
+ * entries (#2839), stages it at the scope's deployment, which then loads the stage, and answers
6446
+ * the tables the host's audited restore judges and records in place of `tables`. Those are every
6447
+ * table's schema with the role rows only, so after a staged restore the host's placeholder holds
6448
+ * no data, and its `_substrat_migrations` and `_substrat_meta` journals are empty, where a whole
6449
+ * dump's restore leaves them full. The scope's own journals are the deployment's.
6450
+ */
6451
+ const loadIntoScope = async (c, scope, input) => {
6452
+ const actor = c.get('actor');
6453
+ const tenantId = tenantIdSchema.parse(scope.tenantId);
6454
+ const scopeId = scopeIdSchema.parse(scope.id);
6455
+ const { header, sourceHint } = input;
6456
+ const origin = scopeIdSchema.parse(header.scopeId);
6457
+ const vertical = await verticalForScope(c, scope);
6458
+ if (input.stage && !vertical) {
6459
+ throw new ControlPlaneError(409, `scope ${scopeId} has no deployment to load a backup stored in parts into`);
6460
+ }
6461
+ // #2185: cleared before the load as well as after it (below): a step after the load can still
6462
+ // throw (the deployment's half, the re-assert), and the data that landed may predate a reconcile.
6463
+ await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null);
6464
+ const staged = input.stage && vertical ? await input.stage(vertical) : undefined;
6465
+ const tables = staged?.tables ?? input.tables;
6466
+ try {
6238
6467
  const storesCaller = options.platformScopeStores;
6239
- const landing = { ...dump, tables, stores: storesCaller && vertical ? [] : dump.stores };
6240
- // #2185: cleared before the load as well as after it (below): a step after the load can still
6241
- // throw (the deployment's half, the re-assert), and the data that landed may predate a reconcile.
6242
- await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null);
6468
+ const landing = { ...header, tables, stores: storesCaller && vertical ? [] : header.stores };
6243
6469
  const hostReplay = await c.var.host.restoreScope(actor, tenantId, scopeId, landing, sourceHint ? { sourceScopeId: sourceHint } : undefined);
6244
- if (storesCaller && vertical && scope.vertical && dump.stores.length > 0) {
6470
+ if (storesCaller && vertical && scope.vertical && header.stores.length > 0) {
6245
6471
  await storesCaller.importStores({
6246
6472
  tenantId,
6247
6473
  scopeId,
6248
6474
  vertical: scope.vertical,
6249
- stores: dump.stores,
6475
+ stores: header.stores,
6250
6476
  });
6251
6477
  }
6252
6478
  const platformReplay = storesCaller && scope.vertical
6253
- ? await replayPlatformStoreErasures(c, scope, sourceHint ?? origin.scopeId)
6479
+ ? await replayPlatformStoreErasures(c, scope, sourceHint ?? origin)
6254
6480
  : undefined;
6255
6481
  const replays = [hostReplay, platformReplay].filter((r) => !!r);
6256
6482
  // #1742: the recorded OFF positions ride the restore, so the deployment switches them
6257
6483
  // off in the replay's own event — its own sweeper cannot land in between.
6258
6484
  const restored = vertical
6259
- ? await restoreCarryingSwitches(actor, vertical, tenantId, scopeId, tables, {
6260
- scopeId: sourceHint ?? origin.scopeId,
6485
+ ? await restoreCarryingSwitches(actor, vertical, tenantId, scopeId, staged?.dump ?? tables, {
6486
+ scopeId: sourceHint ?? origin,
6261
6487
  exact: false,
6262
6488
  })
6263
6489
  : undefined;
@@ -6271,31 +6497,37 @@ export function createControlPlaneApi(options) {
6271
6497
  // finished: the install reads as not reconciled, and the sweep's next pass reconciles it.
6272
6498
  // Again here, after the load, for a sweep reconcile that wrote the receipt while it ran.
6273
6499
  await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null);
6274
- return c.json({
6500
+ return {
6275
6501
  restored: scopeId,
6276
6502
  tables: tables.length,
6277
6503
  // #2820: what the deployment serving the scope did with the dump's own indexes and triggers.
6278
6504
  ...(restored ? { moduleObjects: restored.moduleObjects } : {}),
6279
6505
  ...storeReplayFields(replays),
6280
- });
6506
+ };
6281
6507
  }
6282
6508
  catch (e) {
6283
- // #2185: and once more on the way out of a failure, for a sweep reconcile that wrote the
6284
- // receipt between the clear above and the throw. Clearing is always safe (at worst one more
6285
- // reconcile); a clear that fails here must not hide the restore's own error.
6286
- await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null).catch(() => undefined);
6287
- if (e instanceof ControlPlaneError) {
6288
- return relayRefusal(c, e);
6289
- }
6290
- // A restore throw is driven by the caller-supplied dump (a shape the target cannot
6291
- // load, a DDL the engine rejects), so DISCLOSE it as an actionable 422 rather than
6292
- // collapsing to the generic 500 `internal error` mapError would produce (#321,
6293
- // secondary obs #2). The route is staff- or own-tenant-gated and the detail is about the
6294
- // dump the caller sent, not another tenant's state.
6295
- const detail = e instanceof Error ? e.message : String(e);
6296
- return refuseStatus(c, 422, 'restore failed — the backup could not be loaded', { detail });
6509
+ // A stage the refused load left at the deployment goes now rather than waiting for a wipe.
6510
+ if (staged && vertical)
6511
+ await abandonCopy({ dest: vertical }, { from: scopeId, into: scopeId, stageId: staged.dump.stageId });
6512
+ throw e;
6297
6513
  }
6298
- });
6514
+ };
6515
+ /** How a refused or failed restore answers, on either route. */
6516
+ const restoreFailed = async (c, scope, e) => {
6517
+ // #2185: and once more on the way out of a failure, for a sweep reconcile that wrote the
6518
+ // receipt between the clear before the load and the throw. Clearing is always safe (at worst
6519
+ // one more reconcile); a clear that fails here must not hide the restore's own error.
6520
+ await c.var.admin.markScopeProvisioned(c.get('actor'), tenantIdSchema.parse(scope.tenantId), scopeIdSchema.parse(scope.id), null).catch(() => undefined);
6521
+ if (e instanceof ControlPlaneError) {
6522
+ return relayRefusal(c, e);
6523
+ }
6524
+ // A restore throw is driven by the dump (a shape the target cannot load, a DDL the engine
6525
+ // rejects), so DISCLOSE it as an actionable 422 rather than collapsing to the generic 500
6526
+ // `internal error` mapError would produce (#321, secondary obs #2). The routes are staff- or
6527
+ // own-tenant-gated and the detail is about the dump, not another tenant's state.
6528
+ const detail = e instanceof Error ? e.message : String(e);
6529
+ return refuseStatus(c, 422, 'restore failed — the backup could not be loaded', { detail });
6530
+ };
6299
6531
  // #1236: when this scope's migrations actually ran — the schema-change history
6300
6532
  // whose instants `_substrat_migrations.applied_at` has held all along with no
6301
6533
  // reader. Read through the vertical that holds the scope's data when one
@@ -7322,6 +7554,7 @@ export function createControlPlaneApi(options) {
7322
7554
  return refuse(c, 'forbidden', 'forbidden');
7323
7555
  }
7324
7556
  input.verticalSlug = slug;
7557
+ let scopeStores = [];
7325
7558
  // `manifestJson` is a string the schema cannot look inside, and every reader parses it
7326
7559
  // with `storedDeployManifest` and 500s on a row it refuses: the per-version reads below
7327
7560
  // (registry, schedules, flow, model, assets), the serving-upload rebuild that promote
@@ -7367,8 +7600,16 @@ export function createControlPlaneApi(options) {
7367
7600
  const connectorProblems = pushedConnectorProblems(checked.data);
7368
7601
  if (connectorProblems.length > 0)
7369
7602
  return refuseManifest({ path: ['connectors'], message: connectorProblems.join('; ') });
7370
- }
7371
- await c.var.admin.publishVersion(c.get('actor'), input);
7603
+ scopeStores = checked.data.scopeStores;
7604
+ }
7605
+ // #2210: publishing records the version's store bindings for the whole vertical's lifecycle
7606
+ // (backup, wipe, erasure, the meter), so its store scripts are put in place first, as a push
7607
+ // does: a driver with no approved release refuses here, and nothing is published. The publish is
7608
+ // made under the push's shared hold (#2816), so a rollout walks this vertical or waits for it.
7609
+ await ensureScopeStoresOf(c.get('actor'), 'deploy', slug, scopeStores, async () => (await ownerOf(c.get('actor'), slug)) ?? null, async (renew) => {
7610
+ await renew();
7611
+ await c.var.admin.publishVersion(c.get('actor'), input);
7612
+ });
7372
7613
  const version = await c.var.admin.getVersion(c.get('actor'), input.id, slug);
7373
7614
  return c.json(version, 201);
7374
7615
  });
@@ -7425,7 +7666,11 @@ export function createControlPlaneApi(options) {
7425
7666
  base: base ?? null,
7426
7667
  digest: await promoteReviewDigest(baseVersion && { ...baseVersion, migrations: baseline ?? null }, { ...incomingVersion, migrations: incoming }),
7427
7668
  };
7428
- return c.json({ migrations: incoming === null ? null : migrationsOnTop(incoming, baseline), review });
7669
+ // #2351: whether the incoming set was read from the version's bundle, or only declared by its
7670
+ // push (pushed before the read, or a bundle that exposes no registrations). A version storing none was
7671
+ // compared with nothing, whatever it recorded.
7672
+ const readFromBundle = incoming !== null && !!(await c.var.admin.versionMigrationsExtracted?.(c.get('actor'), slug, incomingId));
7673
+ return c.json({ migrations: incoming === null ? null : migrationsOnTop(incoming, baseline), review, readFromBundle });
7429
7674
  });
7430
7675
  // The emitted entity model (#1214) one version ships: entities, field schemas, parent
7431
7676
  // edges, declared lifecycles — read out of the same retained manifest as the registry
@@ -7661,57 +7906,78 @@ export function createControlPlaneApi(options) {
7661
7906
  * (#559), then rethrown, so the push or serve answers it and the vertical upload never runs.
7662
7907
  * The row carries the vertical's owner, so it shows in that tenant's failure view; the serve
7663
7908
  * path reads the owner only once something has failed.
7909
+ *
7910
+ * `then` runs after the uploads, still under the shared holds (#2816): a push or a publish passes
7911
+ * the step that records the version's store declarations, so a rollout either finds the vertical
7912
+ * in what it walks or is refused until that step is done. `renew` renews every hold, and refuses
7913
+ * (409) once one has lapsed; `then` calls it right before it records. A failure in `then` is not
7914
+ * recorded here: it is the caller's to answer.
7664
7915
  */
7665
- const ensureScopeStoresOf = async (actor, operation, slug, stores, owner) => {
7916
+ const ensureScopeStoresOf = async (actor, operation, slug, stores, owner, then = async () => undefined) => {
7666
7917
  if (stores.length === 0)
7667
- return;
7918
+ return then(async () => undefined);
7919
+ // D-67: a driver whose registration names its verticals serves those only, refused before any
7920
+ // hold is taken or anything is uploaded.
7921
+ for (const store of stores) {
7922
+ const registration = (options.storeDrivers ?? STORE_DRIVERS).find((k) => k.driver === store.driver);
7923
+ if (registration && !storeDriverServesVertical(registration, slug)) {
7924
+ throw new ControlPlaneError(403, `${operation} refused: scope store driver '${store.driver}' does not serve vertical '${slug}'`);
7925
+ }
7926
+ }
7668
7927
  const holder = ulid();
7669
7928
  const drivers = [...new Set(stores.map((store) => store.driver))].sort();
7670
7929
  const held = [];
7930
+ const renewHolds = async (when) => {
7931
+ for (const each of held) {
7932
+ if (!(await host.admin.holdStoreDriver(actor, { driver: each, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS, renew: true }))) {
7933
+ throw new ControlPlaneError(409, `${operation} refused: the hold on scope store driver '${each}' lapsed ${when}; try again`);
7934
+ }
7935
+ }
7936
+ };
7937
+ const renew = () => renewHolds('during the upload');
7671
7938
  try {
7672
- if (!options.ensureScopeStores) {
7673
- throw new ControlPlaneError(501, `${operation} refused: '${slug}' declares a scope store and this control plane uploads no store script`);
7674
- }
7675
- // A shared hold on each driver (#2300): a rollout holds it exclusively, and a rebuild that read
7676
- // a script's release before a rollout moved it would upload the old release back over it.
7677
- // Neither the refusal nor anything else here names another vertical: a push answers its own.
7678
- for (const driver of drivers) {
7679
- if (!(await host.admin.holdStoreDriver(actor, { driver, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS }))) {
7680
- throw new ControlPlaneError(409, `${operation} refused: scope store driver '${driver}' is being rolled out; try again in a few minutes`);
7939
+ try {
7940
+ if (!options.ensureScopeStores) {
7941
+ throw new ControlPlaneError(501, `${operation} refused: '${slug}' declares a scope store and this control plane uploads no store script`);
7681
7942
  }
7682
- held.push(driver);
7683
- }
7684
- const loadRelease = options.storeReleases
7685
- ? storedReleaseLoader({ admin: host.admin, actor, bytes: options.storeReleases.bytes })
7686
- : async () => undefined;
7687
- // One driver at a time, each renewing every hold first: a push whose uploads outlast a hold
7688
- // stops (409) rather than upload after a rollout could have taken the driver.
7689
- for (const driver of drivers) {
7690
- for (const each of held) {
7691
- if (!(await host.admin.holdStoreDriver(actor, { driver: each, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS, renew: true }))) {
7692
- throw new ControlPlaneError(409, `${operation} refused: the hold on scope store driver '${each}' lapsed during the upload; try again`);
7943
+ // A shared hold on each driver (#2300): a rollout holds it exclusively, and a rebuild that read
7944
+ // a script's release before a rollout moved it would upload the old release back over it.
7945
+ // Neither the refusal nor anything else here names another vertical: a push answers its own.
7946
+ for (const driver of drivers) {
7947
+ if (!(await host.admin.holdStoreDriver(actor, { driver, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS }))) {
7948
+ throw new ControlPlaneError(409, `${operation} refused: scope store driver '${driver}' is being rolled out; try again in a few minutes`);
7693
7949
  }
7950
+ held.push(driver);
7951
+ }
7952
+ const loadRelease = options.storeReleases
7953
+ ? storedReleaseLoader({ admin: host.admin, actor, bytes: options.storeReleases.bytes })
7954
+ : async () => undefined;
7955
+ // One driver at a time, each renewing every hold first: a push whose uploads outlast a hold
7956
+ // stops (409) rather than upload after a rollout could have taken the driver.
7957
+ for (const driver of drivers) {
7958
+ await renew();
7959
+ const registration = (options.storeDrivers ?? STORE_DRIVERS).find((k) => k.driver === driver);
7960
+ if (!registration)
7961
+ throw new ControlPlaneError(403, `scope store driver '${driver}' is not registered`);
7962
+ // Read under the shared hold (D-65): a revoke takes the driver exclusively, so one written
7963
+ // before this read is seen, and none is written until this upload is done.
7964
+ const approvals = await readStoreDriverApprovals(host.admin, actor, registration);
7965
+ await options.ensureScopeStores({ vertical: slug, stores: stores.filter((store) => store.driver === driver), loadRelease, approvals });
7694
7966
  }
7695
- const registration = (options.storeDrivers ?? STORE_DRIVERS).find((k) => k.driver === driver);
7696
- if (!registration)
7697
- throw new ControlPlaneError(403, `scope store driver '${driver}' is not registered`);
7698
- // Read under the shared hold (D-65): a revoke takes the driver exclusively, so one written
7699
- // before this read is seen, and none is written until this upload is done.
7700
- const approvals = await readStoreDriverApprovals(host.admin, actor, registration);
7701
- await options.ensureScopeStores({ vertical: slug, stores: stores.filter((store) => store.driver === driver), loadRelease, approvals });
7702
7967
  }
7703
- }
7704
- catch (e) {
7705
- recordFailure({
7706
- actor,
7707
- operation: `${operation}.store-upload`,
7708
- stage: 'store-upload',
7709
- vertical: slug,
7710
- tenantId: await owner().catch(() => null),
7711
- status: e instanceof ControlPlaneError ? e.status : 502,
7712
- message: e instanceof Error ? e.message : String(e),
7713
- }, e);
7714
- throw e;
7968
+ catch (e) {
7969
+ recordFailure({
7970
+ actor,
7971
+ operation: `${operation}.store-upload`,
7972
+ stage: 'store-upload',
7973
+ vertical: slug,
7974
+ tenantId: await owner().catch(() => null),
7975
+ status: e instanceof ControlPlaneError ? e.status : 502,
7976
+ message: e instanceof Error ? e.message : String(e),
7977
+ }, e);
7978
+ throw e;
7979
+ }
7980
+ return await then(() => renewHolds('before the version was recorded'));
7715
7981
  }
7716
7982
  finally {
7717
7983
  for (const driver of held)
@@ -7752,6 +8018,59 @@ export function createControlPlaneApi(options) {
7752
8018
  recordFailure({ actor, operation: 'deploy.wire-record', vertical, version: versionId, message: `the capability record of ${deploymentRef} was not written` }, error);
7753
8019
  }
7754
8020
  };
8021
+ const readBundleMigrations = async (bundle, declared) => {
8022
+ if (!options.extractMigrations)
8023
+ return { kind: 'unmarked' };
8024
+ let registered;
8025
+ try {
8026
+ registered = await options.extractMigrations(bundle);
8027
+ }
8028
+ catch (e) {
8029
+ if (e instanceof MigrationLoaderUnavailable)
8030
+ return { kind: 'unavailable', message: e.message };
8031
+ if (!(e instanceof MigrationExtractionError))
8032
+ throw e;
8033
+ return { kind: 'failed', message: e.message };
8034
+ }
8035
+ if (!registered)
8036
+ return { kind: 'unmarked' };
8037
+ const problem = declared && extractedMigrationsProblem(declared, registered);
8038
+ if (problem)
8039
+ return { kind: 'differs', message: extractedMigrationsRefusal(problem) };
8040
+ return { kind: 'read', digest: await extractedMigrationsDigest(registered) };
8041
+ };
8042
+ /**
8043
+ * #2351: a version pushed before its bundle was read is read once, at its first promote, from
8044
+ * its archive script. Same set: recorded, and the promote goes on. Another set: the refusal,
8045
+ * before anything moves. No registrations, or a bundle that cannot be read here (it was
8046
+ * accepted at push, before this read existed): the promote goes on as it did, unrecorded, so such
8047
+ * a version is read again at every promote. A version storing no migrations has nothing to
8048
+ * compare, and one that is not admitted is refused by the promote without a read.
8049
+ */
8050
+ const bundleMigrationsRefusal = async (admin, actor, slug, versionId) => {
8051
+ if (!options.extractMigrations || !options.fetchVerticalModules || !admin.versionMigrationsExtracted || !admin.recordMigrationsExtracted) {
8052
+ return null;
8053
+ }
8054
+ const [extracted, declared, version, manifestJson] = await Promise.all([
8055
+ admin.versionMigrationsExtracted(actor, slug, versionId),
8056
+ admin.versionMigrations(actor, slug, versionId),
8057
+ admin.getVersion(actor, versionId, slug),
8058
+ admin.versionManifest(actor, slug, versionId),
8059
+ ]);
8060
+ if (extracted !== null || declared === null || version?.admission !== 'admitted' || !version.deploymentRef || !manifestJson)
8061
+ return null;
8062
+ const manifest = storedDeployManifest.parse(JSON.parse(manifestJson));
8063
+ // An archive that cannot be read back is the serve's to report, after the promote, as it is today.
8064
+ const modules = await options.fetchVerticalModules(version.deploymentRef).catch(() => null);
8065
+ if (!modules)
8066
+ return null;
8067
+ const read = await readBundleMigrations({ entry: manifest.entry, compatibilityDate: manifest.compatibilityDate, compatibilityFlags: manifest.compatibilityFlags, modules }, declared);
8068
+ if (read.kind === 'differs')
8069
+ return read.message;
8070
+ if (read.kind === 'read')
8071
+ await admin.recordMigrationsExtracted(actor, slug, versionId, read.digest);
8072
+ return null;
8073
+ };
7755
8074
  /**
7756
8075
  * Serve a version IN PLACE (#286): upload its bundle onto the vertical's ONE stable
7757
8076
  * serving script, where every scope's data DO lives — this is the step that makes a
@@ -8144,6 +8463,10 @@ export function createControlPlaneApi(options) {
8144
8463
  const answer = roleStateAnswer(roles, confinedTenant(p));
8145
8464
  if (answer)
8146
8465
  return refuseStatus(c, answer.status, answer.message, answer.extra);
8466
+ // #2351: a version whose bundle was never read is read now, before anything moves.
8467
+ const bundleRefusal = await bundleMigrationsRefusal(c.var.admin, c.get('actor'), slug, versionId);
8468
+ if (bundleRefusal)
8469
+ return refuseStatus(c, 422, bundleRefusal);
8147
8470
  // The blast-radius moment: refuses a changed digest without the acknowledgement,
8148
8471
  // and refuses a non-admitted version. Both are enforced below the seam and
8149
8472
  // surface as a 4xx through mapError, not a 500.
@@ -8592,123 +8915,150 @@ export function createControlPlaneApi(options) {
8592
8915
  const sweeper = platformSweeperDecision(manifest, { entry: manifest.entry, modules });
8593
8916
  if ('refuse' in sweeper)
8594
8917
  return refuseStatus(c, 422, sweeper.refuse);
8918
+ // #2351: the migrations the bundle registers, read before anything is uploaded. Where the
8919
+ // manifest carries migrations they must be the registered ones, or the push is refused, and
8920
+ // so is a bundle that cannot be evaluated or does not settle: the stored set is then provably
8921
+ // what the version runs. A declared `[]` is a set like any other: the bundle must register
8922
+ // none. A loader that fails is the platform's, so that push answers 503 and may be retried. A
8923
+ // manifest that leaves the field off (over the caps a manifest carries) stores none, so nothing
8924
+ // is gated: its push is accepted however the read went, and records the digest of what was
8925
+ // read, if anything was.
8926
+ const read = await readBundleMigrations({ entry: manifest.entry, compatibilityDate: manifest.compatibilityDate, compatibilityFlags: manifest.compatibilityFlags, modules }, manifest.migrations ?? null);
8927
+ if (read.kind === 'differs' || (read.kind === 'failed' && manifest.migrations))
8928
+ return refuseStatus(c, 422, read.message);
8929
+ if (read.kind === 'unavailable' && manifest.migrations)
8930
+ return refuseStatus(c, 503, read.message);
8931
+ const migrationsExtracted = read.kind === 'read' ? read.digest : null;
8595
8932
  // Mint the version id first: the deploymentRef (the dispatch script name) is keyed
8596
8933
  // on it, so it is CF-valid and unique per version.
8597
8934
  const id = ulid();
8598
8935
  const deploymentRef = deploymentRefFor(slug, id);
8599
8936
  // #2210: the store scripts first, outside the vertical upload's try, so a store failure
8600
- // answers as itself and the vertical is not uploaded at all.
8601
- await ensureScopeStoresOf(c.get('actor'), 'deploy', slug, manifest.scopeStores, async () => ownerTenant ?? null);
8602
- let wire;
8603
- try {
8604
- wire = await deployVerticalScript(options.deployVertical, c.get('actor'), slug, deploymentRef, {
8605
- entry: manifest.entry,
8606
- compatibilityDate: manifest.compatibilityDate,
8607
- compatibilityFlags: manifest.compatibilityFlags,
8608
- modules,
8609
- doClasses: manifest.doClasses,
8610
- bindings: verticalUploadBindings({
8937
+ // answers as itself and the vertical is not uploaded at all. The upload, the registration and
8938
+ // the publish run under the store push's shared hold (#2816): a rollout that starts before the
8939
+ // version records its stores is refused, and one after walks this vertical.
8940
+ const deployVertical = options.deployVertical;
8941
+ const pushed = await ensureScopeStoresOf(c.get('actor'), 'deploy', slug, manifest.scopeStores, async () => ownerTenant ?? null, async (renew) => {
8942
+ let wire;
8943
+ try {
8944
+ wire = await deployVerticalScript(deployVertical, c.get('actor'), slug, deploymentRef, {
8945
+ entry: manifest.entry,
8946
+ compatibilityDate: manifest.compatibilityDate,
8947
+ compatibilityFlags: manifest.compatibilityFlags,
8948
+ modules,
8949
+ doClasses: manifest.doClasses,
8950
+ bindings: verticalUploadBindings({
8951
+ vertical: slug,
8952
+ manifest,
8953
+ drivers: options.storeDrivers,
8954
+ // #2349: the migrations this push declares, which are the ones the version stores.
8955
+ platformBindings: await migrationsDigestBindings(manifest.migrations ?? null),
8956
+ }),
8957
+ // #1242: the freshly minted version id — this archive script IS that version,
8958
+ // and a preview routed at it stamps the signals `version` dimension honestly.
8959
+ versionId: id,
8960
+ // #1902: the platform's scope sweeper, as decided above and recorded with the version.
8961
+ ...(sweeper.supply ? { supplySweeper: true } : {}),
8962
+ // #340: the verified bytes go up with the bundle. The manifest's routing config
8963
+ // rides along untouched — it decides what the RUNTIME does with paths, and carries
8964
+ // no reach, so there is nothing in it for the sandbox contract to refuse.
8965
+ ...(manifest.assets ? { assets: { ...manifest.assets, files: assets } } : {}),
8966
+ });
8967
+ }
8968
+ catch (e) {
8969
+ // The upload to the runtime failed. Surface the detail (the builder is
8970
+ // authenticated) rather than the anonymous 500 the generic handler would give, so a
8971
+ // push failure is diagnosable without reading worker logs. The version label is NOT
8972
+ // consumed here: registration/publish happen only AFTER a successful upload (below),
8973
+ // so a failed push leaves the same --version reusable (#307).
8974
+ //
8975
+ // Answer the upstream status honestly: a runtime 4xx is a bad-bundle rejection — the
8976
+ // builder's own script (e.g. a module-top-level throw → CF 10021), well-formed HTTP
8977
+ // but refused — so a 422, not a 502 that reads as a platform outage. A 5xx (or any
8978
+ // throw with no upstream status) is a platform failure and stays a 502.
8979
+ const detail = e instanceof Error ? e.message : String(e);
8980
+ const upstream = upstreamStatusOf(e);
8981
+ console.error('deploy.upload.failed', { slug, deploymentRef, detail, upstream });
8982
+ const rejected = upstream !== undefined && upstream >= 400 && upstream < 500;
8983
+ // Both outcomes land an ops-failure row (#559): the 502 is the platform's to
8984
+ // explain, and the 422 is what the builder-facing failure view (step 5) will
8985
+ // list — a red push should be explainable from a durable record either way.
8986
+ recordFailure({
8987
+ actor: c.get('actor'),
8988
+ operation: 'deploy.upload',
8989
+ stage: 'wfp-upload',
8611
8990
  vertical: slug,
8612
- manifest,
8613
- drivers: options.storeDrivers,
8614
- // #2349: the migrations this push declares, which are the ones the version stores.
8615
- platformBindings: await migrationsDigestBindings(manifest.migrations ?? null),
8616
- }),
8617
- // #1242: the freshly minted version id — this archive script IS that version,
8618
- // and a preview routed at it stamps the signals `version` dimension honestly.
8619
- versionId: id,
8620
- // #1902: the platform's scope sweeper, as decided above and recorded with the version.
8621
- ...(sweeper.supply ? { supplySweeper: true } : {}),
8622
- // #340: the verified bytes go up with the bundle. The manifest's routing config
8623
- // rides along untouched — it decides what the RUNTIME does with paths, and carries
8624
- // no reach, so there is nothing in it for the sandbox contract to refuse.
8625
- ...(manifest.assets ? { assets: { ...manifest.assets, files: assets } } : {}),
8991
+ tenantId: ownerTenant ?? null,
8992
+ status: rejected ? 422 : 502,
8993
+ message: detail,
8994
+ }, e);
8995
+ c.set('failureRecorded', true);
8996
+ return rejected
8997
+ ? refuseStatus(c, 422, 'deploy rejected', { detail })
8998
+ : refuseStatus(c, 502, 'deploy upload failed', { detail });
8999
+ }
9000
+ // Register-then-publish, both idempotent-ish below the seam: a first push of a
9001
+ // slug registers it; publishVersion lands the version pending with deploymentRef
9002
+ // (or admitted, for a PRIVATE vertical — the registry's self-admit rule). The owner
9003
+ // was resolved with the slug above: the builder's tenant, the pinned workspace, or
9004
+ // the preserved existing owner for an unpinned staff push. An upload that outlasted the
9005
+ // store hold refuses here (409) before the vertical or its version is registered, so the
9006
+ // label stays reusable; unlike a failed upload, it lands no ops-failure row.
9007
+ await renew();
9008
+ await c.var.admin.registerVertical(c.get('actor'), {
9009
+ slug,
9010
+ name: manifest.name ?? slug,
9011
+ source: 'cli',
9012
+ ownerTenant,
9013
+ // The vertical's declared config surface rides to the registry, so the dashboard
9014
+ // renders a settings form for a pushed vertical exactly like a builtin.
9015
+ ...(manifest.envSpec ? { envSpec: manifest.envSpec } : {}),
9016
+ // Registry-driven install metadata (marketplace-publish.md §3) — so the dashboard
9017
+ // installs a pushed vertical without a hardcoded catalog entry.
9018
+ ...(manifest.ownerGrants ? { ownerGrants: manifest.ownerGrants } : {}),
9019
+ ...(manifest.entitlements ? { entitlements: manifest.entitlements } : {}),
9020
+ ...(manifest.provides ? { provides: manifest.provides } : {}),
9021
+ ...(manifest.requires ? { requires: manifest.requires } : {}),
9022
+ // The declared vertical-defined connectors (vertical-connectors.md §7.2): what the dashboard's
9023
+ // door renders. The proxy never reads this copy; it reads the running version's manifest.
9024
+ ...(manifest.connectors ? { connectors: manifest.connectors } : {}),
9025
+ // The declared provisioner intent (#455) — a REQUEST the console reviews; the
9026
+ // tenant-provisioner grant itself is never touched by a push. Refreshes on every
9027
+ // re-push like the rest of the install spec, exactly because it grants nothing.
9028
+ ...(manifest.provisions ? { provisions: manifest.provisions } : {}),
9029
+ // The declared email-sender intent (#303) — a REQUEST the console reviews; the
9030
+ // `emailSender` grant itself is never touched by a push. Refreshes on every re-push
9031
+ // like the rest of the install spec, exactly because it grants nothing.
9032
+ ...(manifest.sendsEmail ? { sendsEmail: true } : {}),
9033
+ // Where the scope lifecycle is held (#1713): `'router'` keeps the vertical's scopes out of
9034
+ // the lifecycle delivery. Refreshes on every re-push like the rest of the install spec.
9035
+ ...(manifest.lifecycle ? { lifecycle: manifest.lifecycle } : {}),
9036
+ // The declared surfaces (K-26) ride like envSpec: registry metadata for the
9037
+ // hostname-binding picker, never behavior. Not part of any admission digest.
9038
+ ...(manifest.surfaces ? { surfaces: manifest.surfaces } : {}),
8626
9039
  });
8627
- }
8628
- catch (e) {
8629
- // The upload to the runtime failed. Surface the detail (the builder is
8630
- // authenticated) rather than the anonymous 500 the generic handler would give, so a
8631
- // push failure is diagnosable without reading worker logs. The version label is NOT
8632
- // consumed here: registration/publish happen only AFTER a successful upload (below),
8633
- // so a failed push leaves the same --version reusable (#307).
8634
- //
8635
- // Answer the upstream status honestly: a runtime 4xx is a bad-bundle rejection — the
8636
- // builder's own script (e.g. a module-top-level throw → CF 10021), well-formed HTTP
8637
- // but refused — so a 422, not a 502 that reads as a platform outage. A 5xx (or any
8638
- // throw with no upstream status) is a platform failure and stays a 502.
8639
- const detail = e instanceof Error ? e.message : String(e);
8640
- const upstream = upstreamStatusOf(e);
8641
- console.error('deploy.upload.failed', { slug, deploymentRef, detail, upstream });
8642
- const rejected = upstream !== undefined && upstream >= 400 && upstream < 500;
8643
- // Both outcomes land an ops-failure row (#559): the 502 is the platform's to
8644
- // explain, and the 422 is what the builder-facing failure view (step 5) will
8645
- // list — a red push should be explainable from a durable record either way.
8646
- recordFailure({
8647
- actor: c.get('actor'),
8648
- operation: 'deploy.upload',
8649
- stage: 'wfp-upload',
8650
- vertical: slug,
8651
- tenantId: ownerTenant ?? null,
8652
- status: rejected ? 422 : 502,
8653
- message: detail,
8654
- }, e);
8655
- c.set('failureRecorded', true);
8656
- return rejected
8657
- ? refuseStatus(c, 422, 'deploy rejected', { detail })
8658
- : refuseStatus(c, 502, 'deploy upload failed', { detail });
8659
- }
8660
- // Register-then-publish, both idempotent-ish below the seam: a first push of a
8661
- // slug registers it; publishVersion lands the version pending with deploymentRef
8662
- // (or admitted, for a PRIVATE vertical — the registry's self-admit rule). The owner
8663
- // was resolved with the slug above: the builder's tenant, the pinned workspace, or
8664
- // the preserved existing owner for an unpinned staff push.
8665
- await c.var.admin.registerVertical(c.get('actor'), {
8666
- slug,
8667
- name: manifest.name ?? slug,
8668
- source: 'cli',
8669
- ownerTenant,
8670
- // The vertical's declared config surface rides to the registry, so the dashboard
8671
- // renders a settings form for a pushed vertical exactly like a builtin.
8672
- ...(manifest.envSpec ? { envSpec: manifest.envSpec } : {}),
8673
- // Registry-driven install metadata (marketplace-publish.md §3) — so the dashboard
8674
- // installs a pushed vertical without a hardcoded catalog entry.
8675
- ...(manifest.ownerGrants ? { ownerGrants: manifest.ownerGrants } : {}),
8676
- ...(manifest.entitlements ? { entitlements: manifest.entitlements } : {}),
8677
- ...(manifest.provides ? { provides: manifest.provides } : {}),
8678
- ...(manifest.requires ? { requires: manifest.requires } : {}),
8679
- // The declared vertical-defined connectors (vertical-connectors.md §7.2): what the dashboard's
8680
- // door renders. The proxy never reads this copy; it reads the running version's manifest.
8681
- ...(manifest.connectors ? { connectors: manifest.connectors } : {}),
8682
- // The declared provisioner intent (#455) — a REQUEST the console reviews; the
8683
- // tenant-provisioner grant itself is never touched by a push. Refreshes on every
8684
- // re-push like the rest of the install spec, exactly because it grants nothing.
8685
- ...(manifest.provisions ? { provisions: manifest.provisions } : {}),
8686
- // The declared email-sender intent (#303) — a REQUEST the console reviews; the
8687
- // `emailSender` grant itself is never touched by a push. Refreshes on every re-push
8688
- // like the rest of the install spec, exactly because it grants nothing.
8689
- ...(manifest.sendsEmail ? { sendsEmail: true } : {}),
8690
- // Where the scope lifecycle is held (#1713): `'router'` keeps the vertical's scopes out of
8691
- // the lifecycle delivery. Refreshes on every re-push like the rest of the install spec.
8692
- ...(manifest.lifecycle ? { lifecycle: manifest.lifecycle } : {}),
8693
- // The declared surfaces (K-26) ride like envSpec: registry metadata for the
8694
- // hostname-binding picker, never behavior. Not part of any admission digest.
8695
- ...(manifest.surfaces ? { surfaces: manifest.surfaces } : {}),
8696
- });
8697
- await c.var.admin.publishVersion(c.get('actor'), {
8698
- id,
8699
- verticalSlug: slug,
8700
- version: manifest.version,
8701
- manifestDigest: manifest.digests.manifest,
8702
- permissionDigest: manifest.digests.permission,
8703
- migrationDigest: manifest.digests.migration,
8704
- deploymentRef,
8705
- ...(origin ? { origin } : {}),
8706
- // Retained for the serving upload (#286): the archive script keeps the module
8707
- // bytes, this keeps their shape (entry, compat, doClasses, bindings).
8708
- // #1902: with the sweeper decision the push made, which no later upload re-makes.
8709
- manifestJson: JSON.stringify({ ...manifest, platformSweeper: sweeper.supply }),
9040
+ await c.var.admin.publishVersion(c.get('actor'), {
9041
+ id,
9042
+ verticalSlug: slug,
9043
+ version: manifest.version,
9044
+ manifestDigest: manifest.digests.manifest,
9045
+ permissionDigest: manifest.digests.permission,
9046
+ migrationDigest: manifest.digests.migration,
9047
+ deploymentRef,
9048
+ ...(origin ? { origin } : {}),
9049
+ // Retained for the serving upload (#286): the archive script keeps the module
9050
+ // bytes, this keeps their shape (entry, compat, doClasses, bindings).
9051
+ // #1902: with the sweeper decision the push made, which no later upload re-makes.
9052
+ manifestJson: JSON.stringify({ ...manifest, platformSweeper: sweeper.supply }),
9053
+ });
9054
+ return { wire };
8710
9055
  });
9056
+ if (pushed instanceof Response)
9057
+ return pushed;
9058
+ const { wire } = pushed;
8711
9059
  await recordWireUpload(c.var.admin, c.get('actor'), id, wire);
9060
+ if (migrationsExtracted)
9061
+ await c.var.admin.recordMigrationsExtracted?.(c.get('actor'), slug, id, migrationsExtracted);
8712
9062
  // Same spirit as the permission-surface gate, advisory tier: when the push DECLARES
8713
9063
  // surfaces, name any surface that hostnames are still bound to but the declaration
8714
9064
  // dropped — the URL keeps resolving (routing never keys on the declaration), it just
@@ -10869,6 +11219,32 @@ export function createControlPlaneApi(options) {
10869
11219
  });
10870
11220
  return c.json(pageOf(entries, filter.limit, (e) => e.id));
10871
11221
  });
11222
+ // -- builder turns: one summary per builder-studio turn, every team -------------
11223
+ // Staff only, and stated here rather than left to the allowlists' default-deny: a
11224
+ // summary names a team and a project, and the turn behind it carries what someone
11225
+ // typed into the studio. The builder answers both; this relays and re-parses.
11226
+ app.get('/builder-turns', async (c) => {
11227
+ if (!staffCaller(c))
11228
+ return refuse(c, 'forbidden', 'forbidden');
11229
+ if (!options.builderTurns)
11230
+ return refuseStatus(c, 501, 'the builder studio is not configured');
11231
+ const url = new URL(c.req.url);
11232
+ const forwarded = new URLSearchParams();
11233
+ for (const key of ['tenantId', 'projectId', 'outcome', 'redGate', 'troubled', 'since', 'limit', 'cursor']) {
11234
+ const value = url.searchParams.get(key);
11235
+ if (value)
11236
+ forwarded.set(key, value);
11237
+ }
11238
+ return c.json(await options.builderTurns.list(forwarded));
11239
+ });
11240
+ app.get('/builder-turns/:turnId', async (c) => {
11241
+ if (!staffCaller(c))
11242
+ return refuse(c, 'forbidden', 'forbidden');
11243
+ if (!options.builderTurns)
11244
+ return refuseStatus(c, 501, 'the builder studio is not configured');
11245
+ const turn = await options.builderTurns.get(c.req.param('turnId'));
11246
+ return turn ? c.json(turn) : refuse(c, 'not_found', 'no such turn');
11247
+ });
10872
11248
  // -- sweep runs (#1232): the durable record of what a sweep pass touched -------
10873
11249
  // Staff read fleet-wide (the console's later view); a builder's tenant is forced
10874
11250
  // from the principal, the /ops-failures posture exactly.
@@ -11192,6 +11568,32 @@ export function createControlPlaneApi(options) {
11192
11568
  const verticals = [...new Set([...declared.verticals.map((v) => v.vertical), ...declared.unreadable])].sort();
11193
11569
  return { fingerprint, ...storeRebuildStatus(walk, fingerprint, verticals, options.storeReleases?.settleMs), walk: walk ?? null };
11194
11570
  };
11571
+ // The scope-store registry a push is judged by, for ONE vertical (#2813): `?vertical=` is the slug the
11572
+ // push deploys as, required (bare, or already prefixed with the caller's own workspace). Every registration comes back, with `verticals` reduced to
11573
+ // `[vertical]` when it serves that vertical and `[]` when it does not (omitted when it serves
11574
+ // every one), so the CLI's own check can say "does not serve" and no other vertical's slug is
11575
+ // read. "Serves" is judged for the registry id the deploy forms (`<tenant>/<name>` for a builder
11576
+ // or a pinned staff push), the same one `ensureScopeStoresOf` judges. The staff-only
11577
+ // `/store-drivers/:driver` below is the driver's release state and stays as it was.
11578
+ app.get('/store-drivers', async (c) => {
11579
+ const parsed = verticalSlug.safeParse(c.req.query('vertical'));
11580
+ if (!parsed.success)
11581
+ return refuse(c, 'validation_failed', 'vertical is required: the slug the push deploys as');
11582
+ const vertical = parsed.data;
11583
+ const p = c.get('principal');
11584
+ const pin = c.req.header(TENANT_HEADER)?.trim();
11585
+ let id = effectiveSlug(p, vertical);
11586
+ if (p.kind !== 'builder' && pin) {
11587
+ // The pinned-staff branch of the deploy's own slug derivation (`POST /verticals/:slug/deploy`).
11588
+ const workspace = (await c.var.admin.listTenants(c.get('actor'))).find((t) => t.slug === pin || t.id === pin);
11589
+ if (!workspace)
11590
+ return refuse(c, 'not_found', `unknown workspace '${pin}'`);
11591
+ const bareOwner = await ownerOf(c.get('actor'), vertical);
11592
+ id = vertical.startsWith(`${workspace.slug}/`) || bareOwner === workspace.id ? vertical : `${workspace.slug}/${vertical}`;
11593
+ }
11594
+ const drivers = (options.storeDrivers ?? STORE_DRIVERS).map((d) => d.verticals ? { ...d, verticals: storeDriverServesVertical(d, id) ? [vertical] : [] } : d);
11595
+ return c.json({ drivers });
11596
+ });
11195
11597
  app.get('/store-drivers/:driver', async (c) => {
11196
11598
  await staffPerson(c, statusWords);
11197
11599
  const registration = registeredDriver(c.req.param('driver'));