@substrat-run/control-plane-api 0.148.0 → 0.150.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 (67) hide show
  1. package/dist/api.d.ts +19 -11
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +321 -93
  4. package/dist/api.js.map +1 -1
  5. package/dist/attachment-sweep.d.ts +30 -0
  6. package/dist/attachment-sweep.d.ts.map +1 -0
  7. package/dist/attachment-sweep.js +80 -0
  8. package/dist/attachment-sweep.js.map +1 -0
  9. package/dist/browser.d.ts +3 -1
  10. package/dist/browser.d.ts.map +1 -1
  11. package/dist/browser.js +3 -0
  12. package/dist/browser.js.map +1 -1
  13. package/dist/cf-observability.d.ts.map +1 -1
  14. package/dist/cf-observability.js +3 -1
  15. package/dist/cf-observability.js.map +1 -1
  16. package/dist/deploy.d.ts +25 -11
  17. package/dist/deploy.d.ts.map +1 -1
  18. package/dist/deploy.js +32 -17
  19. package/dist/deploy.js.map +1 -1
  20. package/dist/index.d.ts +9 -7
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +6 -4
  23. package/dist/index.js.map +1 -1
  24. package/dist/observability.d.ts +28 -0
  25. package/dist/observability.d.ts.map +1 -1
  26. package/dist/observability.js +35 -0
  27. package/dist/observability.js.map +1 -1
  28. package/dist/platform-entry.generated.d.ts +1 -1
  29. package/dist/platform-entry.generated.d.ts.map +1 -1
  30. package/dist/platform-entry.generated.js +2 -2
  31. package/dist/platform-entry.generated.js.map +1 -1
  32. package/dist/platform-scope-stores.d.ts +12 -12
  33. package/dist/platform-scope-stores.d.ts.map +1 -1
  34. package/dist/platform-scope-stores.js +21 -21
  35. package/dist/platform-scope-stores.js.map +1 -1
  36. package/dist/scope-copy-cleanup.d.ts +1 -1
  37. package/dist/scope-copy-cleanup.d.ts.map +1 -1
  38. package/dist/scope-copy-cleanup.js.map +1 -1
  39. package/dist/staff-client.d.ts +10 -2
  40. package/dist/staff-client.d.ts.map +1 -1
  41. package/dist/staff-client.js +6 -1
  42. package/dist/staff-client.js.map +1 -1
  43. package/dist/store-entry.d.ts +19 -19
  44. package/dist/store-entry.d.ts.map +1 -1
  45. package/dist/store-entry.generated.d.ts +16 -16
  46. package/dist/store-entry.generated.d.ts.map +1 -1
  47. package/dist/store-entry.generated.js +19 -19
  48. package/dist/store-entry.generated.js.map +1 -1
  49. package/dist/store-entry.js +43 -43
  50. package/dist/store-entry.js.map +1 -1
  51. package/dist/store-rebuild.d.ts +142 -0
  52. package/dist/store-rebuild.d.ts.map +1 -0
  53. package/dist/store-rebuild.js +223 -0
  54. package/dist/store-rebuild.js.map +1 -0
  55. package/dist/store-releases.d.ts +60 -49
  56. package/dist/store-releases.d.ts.map +1 -1
  57. package/dist/store-releases.js +134 -100
  58. package/dist/store-releases.js.map +1 -1
  59. package/dist/store-script.d.ts +37 -19
  60. package/dist/store-script.d.ts.map +1 -1
  61. package/dist/store-script.js +38 -23
  62. package/dist/store-script.js.map +1 -1
  63. package/dist/vertical-client.d.ts +29 -2
  64. package/dist/vertical-client.d.ts.map +1 -1
  65. package/dist/vertical-client.js +54 -2
  66. package/dist/vertical-client.js.map +1 -1
  67. package/package.json +6 -6
package/dist/api.js CHANGED
@@ -1,16 +1,17 @@
1
1
  import { PREVIEW_CONNECTIONS_REFUSAL } from './connection-relay.js';
2
2
  import { resolveObservabilityWindow } from './observability-window.js';
3
3
  import { Hono } from 'hono';
4
- import { permissionKey, parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, lakeEventsInput, ownerTransferInput, memberInviteInput, memberRoleInput, ASSET_PART_PREFIX, assetHash, STORE_KINDS, STORE_RELEASE_MAX_BYTES, 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, 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, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, lifecycleFlowInput, operationSeriesCount, operationSeriesInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, peerSwitch, verticalSlug, 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, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, validationIssuesFrom, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, errorCodeOf, capabilityFilterQuery, capabilityId as capabilityIdSchema, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, } from '@substrat-run/contracts';
5
- import { SCOPE_COPY_LEASE_MS, copyRestoreFence, replayStoreErasures } from '@substrat-run/kernel';
4
+ import { permissionKey, parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, lakeEventsInput, ownerTransferInput, memberInviteInput, memberRoleInput, ASSET_PART_PREFIX, assetHash, STORE_DRIVERS, STORE_RELEASE_MAX_BYTES, 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, 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, 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, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, validationIssuesFrom, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, substratError, errorCodeOf, capabilityFilterQuery, capabilityId as capabilityIdSchema, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, } from '@substrat-run/contracts';
5
+ import { SCOPE_COPY_LEASE_MS, copyRestoreFence, publishedMigrationDigest, replayStoreErasures } from '@substrat-run/kernel';
6
6
  import { attributeFailure } from './failure-attribution.js';
7
7
  import { auditedChange, withAuditedOutcomes } from './audited-change.js';
8
- import { BIND_EXPORT_BREAK_REFUSAL, CARRIED_AWAY_KEY, bindExportBreakRefusal, carriedAwayDump, dumpMetaValue, metaValueIn, crossVerticalHealth, exportBreakRefusal, isBindExportBreakRefusal, isExportBreakRefusal, isPrimaryScope, migrationProgress, ulid, auditWarningOf, } from '@substrat-run/kernel';
8
+ import { BIND_EXPORT_BREAK_REFUSAL, CARRIED_AWAY_KEY, bindExportBreakRefusal, carriedAwayDump, dumpMetaValue, metaValueIn, crossVerticalHealth, exportBreakRefusal, isBindExportBreakRefusal, isExportBreakRefusal, isPrimaryScope, promoteReviewDigest, rebindReviewDigest, assertRebindReviewCurrent, migrationProgress, ulid, auditWarningOf, } from '@substrat-run/kernel';
9
9
  import { TENANT_HEADER, confinedTenant } from './auth.js';
10
10
  import { mintTenantToken } from './tenant-token.js';
11
11
  import { connectionGrantsForScope } from './vertical-client.js';
12
12
  import { runningVersionOf, runningVersionStoresOf, scopeStoreReachOf, scopeStoresOf, unrouteScope } from './platform-scope-stores.js';
13
- import { PUSH_HOLD_TTL_MS, runStoreKindRollout, storeKindRelease, storedReleaseLoader } from './store-releases.js';
13
+ import { STORE_REBUILD_REQUEST_SUBREQUESTS, runStoreFleetRebuild, storeRebuildBudget, storeRebuildStatus } from './store-rebuild.js';
14
+ import { PUSH_HOLD_TTL_MS, runStoreDriverRollout, storeDriverRelease, storedReleaseLoader } from './store-releases.js';
14
15
  import { assertNoUnreachableScopeCopies, claimScopeScriptReap, drainScopeScriptCopies, listAllScopeScriptCopies, recordStrandedStorage } from './scope-copy-cleanup.js';
15
16
  import { BACKFILL_PAGE_MAX, backfillScopeScriptCopies } from './scope-copy-backfill.js';
16
17
  import { oidcCallbackUrl, retireClientsOfReapedScope, wirePreviewAuth } from './preview-auth.js';
@@ -24,7 +25,7 @@ import { NON_PROBLEM_REFUSAL, coded, mapError, relayed } from './errors.js';
24
25
  import { maskDump, maskRecords } from './mask.js';
25
26
  import { createPseudonymizer } from './pseudonymize.js';
26
27
  import { openDump, sealDump } from './seal.js';
27
- import { assertSandboxContract, verticalUploadBindings, deployManifest, storedDeployManifest, pushedPermissionRegistry, deploymentRefFor, stableDeploymentRefFor, nextMigrationTag, upstreamStatusOf, } from './deploy.js';
28
+ import { assertSandboxContract, verticalUploadBindings, migrationsDigestBindings, deployManifest, storedDeployManifest, pushedPermissionRegistry, deploymentRefFor, stableDeploymentRefFor, nextMigrationTag, upstreamStatusOf, } from './deploy.js';
28
29
  /**
29
30
  * Answer with a problem document (#113 phase 4).
30
31
  *
@@ -297,6 +298,8 @@ const rebindScopeVerticalBody = z.object({
297
298
  // version carry the SAME migration digest — or the operator acknowledges having
298
299
  // read both migration surfaces. Same discipline as a promote's `--ack-migrations`.
299
300
  ackMigrations: z.boolean().optional(),
301
+ // (#2347: the acknowledgement names the review it answers, `acknowledge.review`, as a
302
+ // promote's does; an `ackMigrations` without one is refused where the digests differ.)
300
303
  // Rebind the DIRECTORY only — no export/restore. For a scope whose source script
301
304
  // predates the `/internal/export` surface (#236) and so cannot be dumped at all.
302
305
  // The data is not deleted: it stays on the source script, which remains the
@@ -304,7 +307,8 @@ const rebindScopeVerticalBody = z.object({
304
307
  // on the target afterwards (`/verticals/:slug/instances` is idempotent, K-31).
305
308
  abandonData: z.boolean().optional(),
306
309
  // #1756: within one lineage the rebind is an adopt, refused when it breaks an app in the tenant.
307
- acknowledge: bindAcknowledgement.optional(),
310
+ // #2347: `review`, the rebind review `ackMigrations` answers (`GET …/rebind-review`).
311
+ acknowledge: rebindAcknowledgement.optional(),
308
312
  });
309
313
  // #1756: an adopt moves the scope onto what its vertical serves, and is refused when that breaks
310
314
  // an app in the tenant. The body is optional, as it always was.
@@ -694,6 +698,8 @@ const TENANT_ROUTES = [
694
698
  { method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/query$/, pin: 'path' },
695
699
  { method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/reap$/, pin: 'path' },
696
700
  { method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/rebind-vertical$/, pin: 'path' },
701
+ // #2347: the review a rebind's migration acknowledgement answers; visible exactly as the rebind is.
702
+ { method: 'GET', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/rebind-review$/, pin: 'path' },
697
703
  { method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/restore$/, pin: 'path' },
698
704
  { method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/rewind$/, pin: 'path' },
699
705
  { method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/snapshots$/, pin: 'path' },
@@ -845,7 +851,7 @@ export function createControlPlaneApi(options) {
845
851
  const { host, authenticate, authenticateBuilder, authenticateTenantService } = options;
846
852
  const copyLeaseMs = options.copyLeaseMs ?? SCOPE_COPY_LEASE_MS;
847
853
  /** The stores of the version the scope's deployment runs (`scopeStoresOf`, #2207). */
848
- const manifestStores = (c, scope, running) => scopeStoresOf(c.var.admin, c.get('actor'), scope, options.storeKinds, running);
854
+ const manifestStores = (c, scope, running) => scopeStoresOf(c.var.admin, c.get('actor'), scope, options.storeDrivers, running);
849
855
  /**
850
856
  * The platform's path to a scope's stores, or nothing: no caller is a co-located host whose
851
857
  * own export, restore, wipe and erasure carry its stores, and a scope that declares none has
@@ -887,7 +893,7 @@ export function createControlPlaneApi(options) {
887
893
  const caller = options.platformScopeStores;
888
894
  if (!caller || !scope.vertical)
889
895
  return undefined;
890
- const { running, all } = await scopeStoreReachOf(c.var.admin, c.get('actor'), scope, options.storeKinds);
896
+ const { running, all } = await scopeStoreReachOf(c.var.admin, c.get('actor'), scope, options.storeDrivers);
891
897
  if (all.length === 0)
892
898
  return undefined;
893
899
  const exported = await caller.exportStores({ tenantId: scope.tenantId, scopeId: scope.id, vertical: scope.vertical, stores: all });
@@ -897,7 +903,7 @@ export function createControlPlaneApi(options) {
897
903
  if (held) {
898
904
  throw new ControlPlaneError(409, `scope ${scope.id}'s store '${held.binding}' was declared by an earlier version of '${scope.vertical}' and ` +
899
905
  `still holds data, and the version the scope runs does not declare it, so a dump of it could not be ` +
900
- `restored. Declare '${held.binding}' as a '${held.kind}' again in a version the scope runs, then export; ` +
906
+ `restored. Declare '${held.binding}' as a '${held.driver}' again in a version the scope runs, then export; ` +
901
907
  `or reap the scope without a backup, which wipes it`);
902
908
  }
903
909
  return exported.filter((store) => current.has(store.binding));
@@ -948,7 +954,7 @@ export function createControlPlaneApi(options) {
948
954
  };
949
955
  /**
950
956
  * A dump's stores, checked against what the destination's version declares (#2207), before
951
- * any write: a binding it does not declare, or one of another kind, would be imported into an
957
+ * any write: a binding it does not declare, or one of another driver, would be imported into an
952
958
  * object nothing reads, or into one of the wrong shape.
953
959
  */
954
960
  const refuseUndeclaredDumpStores = async (c, scope, stores) => {
@@ -958,15 +964,15 @@ export function createControlPlaneApi(options) {
958
964
  throw new ControlPlaneError(422, 'restore refused: the dump names stores and the scope names no vertical');
959
965
  // The destination's running version only (L8): a binding only an earlier version declared
960
966
  // would be imported into an object nothing in the code the scope runs reads.
961
- const declared = new Map((await runningVersionStoresOf(c.var.admin, c.get('actor'), scope, options.storeKinds))
962
- .map((d) => [d.binding, d.kind]));
967
+ const declared = new Map((await runningVersionStoresOf(c.var.admin, c.get('actor'), scope, options.storeDrivers))
968
+ .map((d) => [d.binding, d.driver]));
963
969
  for (const store of stores) {
964
- const kind = declared.get(store.binding);
965
- if (kind === undefined) {
970
+ const driver = declared.get(store.binding);
971
+ if (driver === undefined) {
966
972
  throw new ControlPlaneError(422, `restore refused: the dump names store '${store.binding}', which this scope's version does not declare`);
967
973
  }
968
- if (kind !== store.kind) {
969
- throw new ControlPlaneError(422, `restore refused: the dump's store '${store.binding}' is a '${store.kind}', and this scope's version declares a '${kind}'`);
974
+ if (driver !== store.driver) {
975
+ throw new ControlPlaneError(422, `restore refused: the dump's store '${store.binding}' is a '${store.driver}', and this scope's version declares a '${driver}'`);
970
976
  }
971
977
  }
972
978
  };
@@ -2044,7 +2050,11 @@ export function createControlPlaneApi(options) {
2044
2050
  .refine((w) => w.toYear >= w.fromYear, 'window ends before it starts').nullable().optional(),
2045
2051
  horizonYears: z.number().int().min(0).optional(),
2046
2052
  }).strict()
2047
- : z.object(fields).strict();
2053
+ : row.provider === 'microsoft365'
2054
+ // #2101: the channel folder, by its drive path. The connector resolves it and holds the
2055
+ // fence to it; this only bounds the input.
2056
+ ? z.object({ ...fields, folder: z.string().trim().min(1).max(400) }).strict()
2057
+ : z.object(fields).strict();
2048
2058
  const input = schema.parse(await c.req.json());
2049
2059
  const grants = await c.var.host.connectionGrantsInScope(row.tenantId, scopeId);
2050
2060
  if (!grants.some((g) => g.connectionId === row.id && g.permission === input.permission)) {
@@ -2488,6 +2498,27 @@ export function createControlPlaneApi(options) {
2488
2498
  }
2489
2499
  return { refused, ...(refusal ? { refusal } : {}) };
2490
2500
  };
2501
+ /**
2502
+ * The two sides of a rebind review (#2347), as the host stores them: the version the scope is
2503
+ * bound to (null: none, or one its lineage no longer holds) and the target lineage's serving
2504
+ * version, each with its stored migrations. The read route and the rebind gate both read
2505
+ * through here, so the digest a review hands out and the one the rebind recomputes are taken
2506
+ * from the same rows the same way.
2507
+ */
2508
+ const rebindSidesOf = async (c, scope, target, servingVersionId) => {
2509
+ const actor = c.get('actor');
2510
+ const side = async (slug, id) => {
2511
+ const v = await c.var.admin.getVersion(actor, id, slug);
2512
+ return v ? { ...v, migrations: await c.var.admin.versionMigrations(actor, slug, id) } : null;
2513
+ };
2514
+ const [base, incoming] = await Promise.all([
2515
+ scope.vertical && scope.verticalVersionId ? side(scope.vertical, scope.verticalVersionId) : null,
2516
+ side(target, servingVersionId),
2517
+ ]);
2518
+ if (!incoming)
2519
+ throw substratError('not_found', `unknown serving version for vertical '${target}'`);
2520
+ return { base, target: incoming };
2521
+ };
2491
2522
  /**
2492
2523
  * Move ONE scope onto a DIFFERENT vertical lineage's serving script (#389) — the
2493
2524
  * update-rebind behind retiring a platform-owned lineage in favour of a tenant-owned
@@ -2525,13 +2556,15 @@ export function createControlPlaneApi(options) {
2525
2556
  await c.var.admin.reassertSystemSwitches(actor, { tenantId, scopeId });
2526
2557
  return { servingRef: serving.ref, versionId: serving.versionId, alreadyBound: true };
2527
2558
  }
2559
+ // #2347: the review binding is the rebind's own; what the host's bind takes is the rest.
2560
+ const { review, ...bindAck } = opts.acknowledge ?? {};
2528
2561
  // #1756: within one lineage this is an adopt, and judged as one before anything moves. A
2529
2562
  // crossing is not judged (`bindExportBreaksOf`): the producer's slug itself changes.
2530
2563
  await refuseOnBreaks(actor, tenantId, scopeId, serving.versionId, {
2531
2564
  servingRef: serving.ref,
2532
- acknowledge: opts.acknowledge,
2565
+ acknowledge: bindAck,
2533
2566
  });
2534
- const move = { acknowledge: opts.acknowledge };
2567
+ const move = { acknowledge: bindAck };
2535
2568
  if (opts.abandonData) {
2536
2569
  // Directory-only crossing: no bytes move, so the frontier gate has nothing to
2537
2570
  // protect — the target provisions its own schema from scratch. The source script's
@@ -2560,15 +2593,22 @@ export function createControlPlaneApi(options) {
2560
2593
  // what this DO has already applied plus whatever a same-lineage update would have
2561
2594
  // brought — the crossing is then no riskier than an ordinary version bind. Anything
2562
2595
  // else (differing digests, or a scope with no bound version to compare) needs the
2563
- // operator's explicit acknowledgement.
2564
- const targetV = await c.var.admin.getVersion(actor, serving.versionId, target);
2565
- const currentV = scope.verticalVersionId
2566
- ? await c.var.admin.getVersion(actor, scope.verticalVersionId, scope.vertical)
2567
- : undefined;
2568
- const digestsMatch = Boolean(currentV && targetV && currentV.migrationDigest === targetV.migrationDigest);
2596
+ // operator's explicit acknowledgement, bound to the review it answers (#2347).
2597
+ const sides = await rebindSidesOf(c, scope, target, serving.versionId);
2598
+ const digestsMatch = Boolean(sides.base && sides.base.migrationDigest === sides.target.migrationDigest);
2599
+ await assertRebindReviewCurrent({
2600
+ ackMigrations: opts.ackMigrations === true,
2601
+ review,
2602
+ target,
2603
+ baseId: scope.verticalVersionId ?? null,
2604
+ migrationsChange: !digestsMatch,
2605
+ read: async () => ({ base: sides.base, baseVertical: scope.vertical, target: sides.target }),
2606
+ });
2569
2607
  if (!digestsMatch && !opts.ackMigrations) {
2570
- throw new ControlPlaneError(409, `migration surfaces differ across lineages ('${scope.vertical}' ${currentV?.migrationDigest ?? '(unbound)'} → ` +
2571
- `'${target}' ${targetV?.migrationDigest ?? '(unknown)'}) — read both migration diffs, then re-run with ackMigrations`);
2608
+ throw new ControlPlaneError(409, `migration surfaces differ across lineages ('${scope.vertical}' ${sides.base?.migrationDigest ?? '(unbound)'} → ` +
2609
+ `'${target}' ${sides.target.migrationDigest}) — read the rebind review (GET ` +
2610
+ `/tenants/:tenantId/scopes/:scopeId/rebind-review?vertical=<target>), then re-run with ackMigrations and the ` +
2611
+ 'acknowledge.review it returned');
2572
2612
  }
2573
2613
  const source = await verticalForScope(c, scope);
2574
2614
  const dest = await options.resolveVerticalRef?.(serving.ref);
@@ -2579,8 +2619,17 @@ export function createControlPlaneApi(options) {
2579
2619
  // pointer cross. `bindScopeVersion` rewrites `scopes.vertical` from the version row, audited.
2580
2620
  // No snapshot is taken here: the old copy is swept once the move is confirmed, so an
2581
2621
  // operator who wants a way back takes a snapshot or backup first.
2582
- const restored = await moveOntoServing(c, scope, source, dest, serving.ref, move);
2583
- await c.var.admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId, move).catch(relayHostRefusal);
2622
+ // #2347: the route flips and the pointer crosses in ONE write, and only if the scope is still
2623
+ // bound to the version the gate judged and the target still serves the version it judged: a
2624
+ // promote on either lineage since then is refused as stale, and the move settles `retained`.
2625
+ const restored = await moveOntoServing(c, scope, source, dest, serving.ref, {
2626
+ ...move,
2627
+ bind: {
2628
+ versionId: serving.versionId,
2629
+ expectedVersionId: scope.verticalVersionId ?? null,
2630
+ expectedServing: { vertical: target, ref: serving.ref, versionId: serving.versionId },
2631
+ },
2632
+ });
2584
2633
  // #1674: re-assert the recorded OFF positions in the store the scope now routes to.
2585
2634
  await c.var.admin.reassertSystemSwitches(actor, { tenantId, scopeId }, { appliedInUnit: restored.switchedOff });
2586
2635
  return { servingRef: serving.ref, versionId: serving.versionId, tables: restored.tables };
@@ -2620,12 +2669,18 @@ export function createControlPlaneApi(options) {
2620
2669
  scopeId, exact: true, loadStamp: restoredStamp,
2621
2670
  ...(moveId ? { fence: copyRestoreFence(moveId, recordedAt, copyLeaseMs, erasureEpoch) } : {}),
2622
2671
  });
2623
- await c.var.admin.setScopeServingRef(actor, tenantId, scopeId, servingRef, {
2624
- ...routeOpts, expectedErasureEpoch: erasureEpoch,
2672
+ const flip = {
2673
+ acknowledge: routeOpts.acknowledge, expectedErasureEpoch: erasureEpoch,
2625
2674
  ...(moveId ? { confirmMove: sourceStamp
2626
2675
  ? { moveId, source: 'eligible', sourceMarker: { loadStamp: sourceStamp, revision: sourceRevision } }
2627
2676
  : { moveId, source: 'retained' } } : {}),
2628
- }).catch(relayHostRefusal);
2677
+ };
2678
+ const { bind } = routeOpts;
2679
+ await (bind
2680
+ ? c.var.admin.bindScopeVersion(actor, tenantId, scopeId, bind.versionId, {
2681
+ ...flip, servingRef, expectedVersionId: bind.expectedVersionId, expectedServing: bind.expectedServing,
2682
+ })
2683
+ : c.var.admin.setScopeServingRef(actor, tenantId, scopeId, servingRef, flip)).catch(relayHostRefusal);
2629
2684
  return restored;
2630
2685
  }
2631
2686
  catch (error) {
@@ -4464,7 +4519,7 @@ export function createControlPlaneApi(options) {
4464
4519
  // and the claim before the key goes requires it, and the serving pointer as read, to still hold.
4465
4520
  const running = await runningVersionOf(c.var.admin, c.get('actor'), scope);
4466
4521
  // #2207: the stores of the version the deployment runs, resolved before anything is erased. A
4467
- // version that declares none is not asked. One that cannot be resolved while a kind is
4522
+ // version that declares none is not asked. One that cannot be resolved while a driver is
4468
4523
  // registered refuses (503): the key must not be destroyed while a store may hold the subject.
4469
4524
  const reach = await platformStoresOf(c, scope, running);
4470
4525
  /**
@@ -5353,7 +5408,7 @@ export function createControlPlaneApi(options) {
5353
5408
  force,
5354
5409
  localApply: !vertical,
5355
5410
  });
5356
- const answer = vertical ? await vertical.rewindScope(scopeId, bookmark, { force }) : result;
5411
+ const answer = vertical ? await vertical.rewindScope(scopeId, bookmark, { force, tenantId }) : result;
5357
5412
  // #1674: a rewind takes the scope's grants back to the bookmark, and with them any
5358
5413
  // schedule kill switch pulled since — a tenant owner could otherwise undo a staff
5359
5414
  // switch this way. Clearing the provisioned receipt, only once the rewind has gone
@@ -5434,10 +5489,10 @@ export function createControlPlaneApi(options) {
5434
5489
  return c.json({ vertical: slug, adopted, alreadyAdopted });
5435
5490
  });
5436
5491
  // Rebind ONE scope onto a different vertical lineage (#389) — the update-rebind that
5437
- // retires a platform-owned lineage in favour of a tenant-owned one. Deliberately NOT
5438
- // in BUILDER_ROUTES: a lineage crossing re-homes a customer's data under a different
5439
- // registry owner, which is a staff decision like admission — the allowlist's
5440
- // default-deny 403s a builder without a line here.
5492
+ // retires a platform-owned lineage in favour of a tenant-owned one. Callers: staff, and a
5493
+ // tenant credential pinned to the tenant in the path (TENANT_ROUTES); the crossing is judged
5494
+ // by `crossingHidden`, the rule the review read shares. Deliberately NOT in BUILDER_ROUTES:
5495
+ // a builder's push token is default-denied it.
5441
5496
  app.post('/tenants/:tenantId/scopes/:scopeId/rebind-vertical', async (c) => {
5442
5497
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
5443
5498
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
@@ -5445,10 +5500,11 @@ export function createControlPlaneApi(options) {
5445
5500
  // #977: the TARGET must be a vertical this principal can read — its own tenant's, or a
5446
5501
  // listed one. The path pin confines whose scope moves; without this, it could move onto
5447
5502
  // another tenant's private vertical, which a confined caller must not even learn exists
5448
- // (404, as `GET /verticals/:slug` answers it).
5449
- if (await notReadable(c.get('principal'), vertical)) {
5450
- return refuse(c, 'not_found', `unknown vertical: ${vertical}`);
5451
- }
5503
+ // (404, as `GET /verticals/:slug` answers it). #2347: the one rule its review read shares.
5504
+ const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
5505
+ // An unknown scope answers the same bytes as a hidden crossing, so the two cannot be told apart.
5506
+ if (!scope || (await crossingHidden(c.get('principal'), scope.vertical ?? null, vertical)))
5507
+ return crossingNotFound(c);
5452
5508
  try {
5453
5509
  const r = await rebindScopeOntoVertical(c, tenantId, scopeId, vertical, {
5454
5510
  ackMigrations,
@@ -5464,6 +5520,55 @@ export function createControlPlaneApi(options) {
5464
5520
  throw e;
5465
5521
  }
5466
5522
  });
5523
+ // #2347: the review a rebind's migration acknowledgement answers — what the target lineage's
5524
+ // serving version adds over the version the scope is bound to, with the binding
5525
+ // (`acknowledge.review`) computed from the very rows this answer shows. The server picks both
5526
+ // sides, so a caller cannot bind a review of a base the scope is not on.
5527
+ //
5528
+ // Who may read it is exactly who may make the rebind it answers (`crossingHidden`): a confined
5529
+ // caller must be able to read both verticals, its own tenant's or a listed one. An unknown
5530
+ // scope, a scope with no vertical, an unknown target and a vertical the caller cannot read all
5531
+ // answer the same 404, so the read is no oracle for another tenant's verticals.
5532
+ // Callers: staff, and a tenant credential pinned to the tenant in the path, as the rebind.
5533
+ //
5534
+ // The migration SQL itself stays owner-only, as `/versions/:id/migrations` keeps it: it describes
5535
+ // a schema. A caller that does not own both verticals gets `migrations: null` and
5536
+ // `migrationsWithheld: true`, with everything else: both sides, whether the digests differ, and
5537
+ // the binding, so it can still acknowledge the review and rebind onto a listed vertical.
5538
+ app.get('/tenants/:tenantId/scopes/:scopeId/rebind-review', async (c) => {
5539
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
5540
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
5541
+ const target = z.string().min(1).parse(c.req.query('vertical'));
5542
+ const actor = c.get('actor');
5543
+ const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
5544
+ if (!scope?.vertical || (await crossingHidden(c.get('principal'), scope.vertical, target)))
5545
+ return crossingNotFound(c);
5546
+ const serving = await c.var.admin.verticalServing(actor, target).catch((e) => {
5547
+ if (errorCodeOf(e) === 'not_found')
5548
+ return undefined;
5549
+ throw e;
5550
+ });
5551
+ if (serving === undefined)
5552
+ return crossingNotFound(c);
5553
+ if (serving === null) {
5554
+ return refuseStatus(c, 409, `vertical '${target}' has no serving script yet — promote a version to prod first`);
5555
+ }
5556
+ const { base, target: incoming } = await rebindSidesOf(c, scope, target, serving.versionId);
5557
+ const p = c.get('principal');
5558
+ const withheld = (await notOwned(p, scope.vertical)) || (await notOwned(p, target));
5559
+ const review = {
5560
+ base: scope.verticalVersionId ?? null,
5561
+ digest: await rebindReviewDigest(base && { vertical: scope.vertical, version: base }, { vertical: target, version: incoming }),
5562
+ };
5563
+ return c.json({
5564
+ base: scope.verticalVersionId ? { vertical: scope.vertical, versionId: scope.verticalVersionId } : null,
5565
+ target: { vertical: target, versionId: incoming.id },
5566
+ digestsMatch: Boolean(base && base.migrationDigest === incoming.migrationDigest),
5567
+ migrations: withheld || incoming.migrations === null ? null : migrationsOnTop(incoming.migrations, base ? base.migrations : undefined),
5568
+ migrationsWithheld: withheld,
5569
+ review,
5570
+ });
5571
+ });
5467
5572
  // #1756: which apps in this tenant binding `versionId` onto the scope would break — the
5468
5573
  // question the bind below refuses on, as a read, so a client can ask it before it records
5469
5574
  // anything of its own (the dashboard's Activity line) and show what an acknowledgement would
@@ -6022,6 +6127,16 @@ export function createControlPlaneApi(options) {
6022
6127
  const v = await verticalOf(p.actor, slug);
6023
6128
  return !v || (v.ownerTenant !== pin && !v.listed);
6024
6129
  };
6130
+ /**
6131
+ * #2347: the one visibility rule for a rebind across verticals and for the review it answers.
6132
+ * A confined caller sees a crossing only when it can read both ends (`notReadable`: its own
6133
+ * tenant's vertical, or a listed one), so whoever may make a rebind may read what it applies, and
6134
+ * nobody reads the migrations of a vertical it could not see. A scope with no vertical (null)
6135
+ * hides nothing here: the rebind refuses it on its own terms.
6136
+ */
6137
+ const crossingHidden = async (p, source, target) => (await notReadable(p, target)) || (source !== null && (await notReadable(p, source)));
6138
+ /** What a hidden crossing answers, the same bytes whichever end is hidden. */
6139
+ const crossingNotFound = (c) => refuse(c, 'not_found', 'not found');
6025
6140
  // The vertical id a request actually addresses. For a BUILDER it is `<tenantSlug>/<name>`
6026
6141
  // (builder-plane.md §5): they send a bare `--slug`, the control plane forms the prefix
6027
6142
  // from their authenticated tenant — so two builders can each own a `helpdesk` with no
@@ -6282,12 +6397,28 @@ export function createControlPlaneApi(options) {
6282
6397
  }
6283
6398
  // `versionMigrations` refuses a version of another vertical, so `base` cannot reach one.
6284
6399
  const migrationsOf = (id) => c.var.admin.versionMigrations(c.get('actor'), slug, id);
6400
+ const versionOf = async (id) => {
6401
+ const v = await c.var.admin.getVersion(c.get('actor'), id, slug);
6402
+ if (!v)
6403
+ throw substratError('not_found', `unknown version ${id} for vertical '${slug}'`);
6404
+ return v;
6405
+ };
6285
6406
  const base = c.req.query('base');
6286
- const [incoming, baseline] = await Promise.all([
6287
- migrationsOf(c.req.param('id')),
6407
+ const incomingId = c.req.param('id');
6408
+ const [incoming, baseline, incomingVersion, baseVersion] = await Promise.all([
6409
+ migrationsOf(incomingId),
6288
6410
  base === undefined ? undefined : migrationsOf(base),
6411
+ versionOf(incomingId),
6412
+ base === undefined ? null : versionOf(base),
6289
6413
  ]);
6290
- return c.json({ migrations: incoming === null ? null : migrationsOnTop(incoming, baseline) });
6414
+ // #2333: the binding a promote's acknowledgement carries, computed from the very rows this
6415
+ // answer shows, in this answer — so a client cannot show one diff and bind another. The
6416
+ // promote recomputes it from its own stored rows and refuses when it no longer matches.
6417
+ const review = {
6418
+ base: base ?? null,
6419
+ digest: await promoteReviewDigest(baseVersion && { ...baseVersion, migrations: baseline ?? null }, { ...incomingVersion, migrations: incoming }),
6420
+ };
6421
+ return c.json({ migrations: incoming === null ? null : migrationsOnTop(incoming, baseline), review });
6291
6422
  });
6292
6423
  // The emitted entity model (#1214) one version ships: entities, field schemas, parent
6293
6424
  // edges, declared lifecycles — read out of the same retained manifest as the registry
@@ -6495,33 +6626,33 @@ export function createControlPlaneApi(options) {
6495
6626
  if (stores.length === 0)
6496
6627
  return;
6497
6628
  const holder = ulid();
6498
- const kinds = [...new Set(stores.map((store) => store.kind))].sort();
6629
+ const drivers = [...new Set(stores.map((store) => store.driver))].sort();
6499
6630
  const held = [];
6500
6631
  try {
6501
6632
  if (!options.ensureScopeStores) {
6502
6633
  throw new ControlPlaneError(501, `${operation} refused: '${slug}' declares a scope store and this control plane uploads no store script`);
6503
6634
  }
6504
- // A shared hold on each kind (#2300): a rollout holds it exclusively, and a rebuild that read
6635
+ // A shared hold on each driver (#2300): a rollout holds it exclusively, and a rebuild that read
6505
6636
  // a script's release before a rollout moved it would upload the old release back over it.
6506
6637
  // Neither the refusal nor anything else here names another vertical: a push answers its own.
6507
- for (const kind of kinds) {
6508
- if (!(await host.admin.holdStoreKind(actor, { kind, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS }))) {
6509
- throw new ControlPlaneError(409, `${operation} refused: scope store kind '${kind}' is being rolled out; try again in a few minutes`);
6638
+ for (const driver of drivers) {
6639
+ if (!(await host.admin.holdStoreDriver(actor, { driver, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS }))) {
6640
+ throw new ControlPlaneError(409, `${operation} refused: scope store driver '${driver}' is being rolled out; try again in a few minutes`);
6510
6641
  }
6511
- held.push(kind);
6642
+ held.push(driver);
6512
6643
  }
6513
6644
  const loadRelease = options.storeReleases
6514
6645
  ? storedReleaseLoader({ admin: host.admin, actor, bytes: options.storeReleases.bytes })
6515
6646
  : async () => undefined;
6516
- // One kind at a time, each renewing every hold first: a push whose uploads outlast a hold
6517
- // stops (409) rather than upload after a rollout could have taken the kind.
6518
- for (const kind of kinds) {
6647
+ // One driver at a time, each renewing every hold first: a push whose uploads outlast a hold
6648
+ // stops (409) rather than upload after a rollout could have taken the driver.
6649
+ for (const driver of drivers) {
6519
6650
  for (const each of held) {
6520
- if (!(await host.admin.holdStoreKind(actor, { kind: each, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS, renew: true }))) {
6521
- throw new ControlPlaneError(409, `${operation} refused: the hold on scope store kind '${each}' lapsed during the upload; try again`);
6651
+ if (!(await host.admin.holdStoreDriver(actor, { driver: each, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS, renew: true }))) {
6652
+ throw new ControlPlaneError(409, `${operation} refused: the hold on scope store driver '${each}' lapsed during the upload; try again`);
6522
6653
  }
6523
6654
  }
6524
- await options.ensureScopeStores({ vertical: slug, stores: stores.filter((store) => store.kind === kind), loadRelease });
6655
+ await options.ensureScopeStores({ vertical: slug, stores: stores.filter((store) => store.driver === driver), loadRelease });
6525
6656
  }
6526
6657
  }
6527
6658
  catch (e) {
@@ -6537,8 +6668,8 @@ export function createControlPlaneApi(options) {
6537
6668
  throw e;
6538
6669
  }
6539
6670
  finally {
6540
- for (const kind of held)
6541
- await host.admin.releaseStoreKindHold(actor, { kind, holder }).catch(() => undefined);
6671
+ for (const driver of held)
6672
+ await host.admin.releaseStoreDriverHold(actor, { driver, holder }).catch(() => undefined);
6542
6673
  }
6543
6674
  };
6544
6675
  /**
@@ -6602,8 +6733,17 @@ export function createControlPlaneApi(options) {
6602
6733
  modules,
6603
6734
  doClasses,
6604
6735
  // #2210: a declared scope store adds no binding to either upload (L1), held at both call
6605
- // sites with a kind registered by upload-scope-store-bindings.test.ts.
6606
- bindings: verticalUploadBindings({ vertical: slug, manifest, platformBindings: storeBindings }),
6736
+ // sites with a driver registered by upload-scope-store-bindings.test.ts.
6737
+ bindings: verticalUploadBindings({
6738
+ vertical: slug,
6739
+ manifest,
6740
+ // #2349: the digest of the served version's stored migrations, which its scopes hold
6741
+ // the SQL they apply to.
6742
+ platformBindings: [
6743
+ ...storeBindings,
6744
+ ...(await migrationsDigestBindings(await admin.versionMigrations(actor, slug, versionId))),
6745
+ ],
6746
+ }),
6607
6747
  // #1242: the version now being served, injected as `SUBSTRAT_VERSION_ID` so the
6608
6748
  // scope host stamps the signals `version` dimension. The serving script is one
6609
6749
  // stable script updated in place, so this refresh is what keeps the binding
@@ -6923,6 +7063,12 @@ export function createControlPlaneApi(options) {
6923
7063
  const manifest = deployManifest.parse(JSON.parse(raw));
6924
7064
  // §4 sandbox contract, before anything reaches the namespace.
6925
7065
  assertSandboxContract(manifest);
7066
+ // #2339: a push whose migration digest is not the one its migrations give is refused here,
7067
+ // before the upload, by the derivation `publishVersion` stores (and refuses with) again.
7068
+ await publishedMigrationDigest({
7069
+ id: 'push', migrationDigest: manifest.digests.migration, manifestJson: JSON.stringify(manifest),
7070
+ migrations: manifest.migrations ?? null,
7071
+ });
6926
7072
  // The workspace this push is FOR — the project's pin (package.json `substrat.tenant`),
6927
7073
  // sent by the CLI alongside the bundle. The pin is intent, and intent is honored or
6928
7074
  // refused, never silently reinterpreted: a BUILDER's workspace is already fixed by
@@ -7118,7 +7264,12 @@ export function createControlPlaneApi(options) {
7118
7264
  compatibilityFlags: manifest.compatibilityFlags,
7119
7265
  modules,
7120
7266
  doClasses: manifest.doClasses,
7121
- bindings: verticalUploadBindings({ vertical: slug, manifest, platformBindings: [] }),
7267
+ bindings: verticalUploadBindings({
7268
+ vertical: slug,
7269
+ manifest,
7270
+ // #2349: the migrations this push declares, which are the ones the version stores.
7271
+ platformBindings: await migrationsDigestBindings(manifest.migrations ?? null),
7272
+ }),
7122
7273
  // #1242: the freshly minted version id — this archive script IS that version,
7123
7274
  // and a preview routed at it stamps the signals `version` dimension honestly.
7124
7275
  versionId: id,
@@ -9010,19 +9161,19 @@ export function createControlPlaneApi(options) {
9010
9161
  const summary = await c.var.admin.summarizeModelUsage(c.get('actor'), { ...(q.tenantId ? { tenantId: q.tenantId } : {}), since, until }, options.modelMarginPercent ?? DEFAULT_MODEL_MARGIN_PERCENT);
9011
9162
  return c.json(summary);
9012
9163
  });
9013
- // -- store-kind releases (D-63, #2300) ----------------------------------------
9164
+ // -- store-driver releases (D-63, #2300) ----------------------------------------
9014
9165
  // Staff only: none of these is on the builder or the tenant allowlist. A write is also made by a
9015
9166
  // person: `humanStaffActor` must name the same actor from a human staff sign-in, so the
9016
9167
  // automation token, which resolves to a staff actor as well, publishes nothing.
9017
- const registeredKind = (kind) => {
9018
- const registration = (options.storeKinds ?? STORE_KINDS).find((k) => k.kind === kind);
9168
+ const registeredDriver = (driver) => {
9169
+ const registration = (options.storeDrivers ?? STORE_DRIVERS).find((k) => k.driver === driver);
9019
9170
  if (!registration)
9020
- throw new ControlPlaneError(403, `scope store kind '${kind}' is not registered`);
9171
+ throw new ControlPlaneError(403, `scope store driver '${driver}' is not registered`);
9021
9172
  return registration;
9022
9173
  };
9023
9174
  const releaseFlow = () => {
9024
9175
  if (!options.storeReleases || !options.ensureScopeStores) {
9025
- throw new ControlPlaneError(501, 'this control plane keeps no store kind releases');
9176
+ throw new ControlPlaneError(501, 'this control plane keeps no store driver releases');
9026
9177
  }
9027
9178
  return { flow: options.storeReleases, ensureScopeStores: options.ensureScopeStores };
9028
9179
  };
@@ -9034,28 +9185,28 @@ export function createControlPlaneApi(options) {
9034
9185
  */
9035
9186
  const byAPerson = async (c) => {
9036
9187
  if (!/^application\/json\s*(;|$)/i.test(c.req.header('content-type') ?? '')) {
9037
- throw new ControlPlaneError(415, 'a store kind release write takes application/json');
9188
+ throw new ControlPlaneError(415, 'a store driver release write takes application/json');
9038
9189
  }
9039
9190
  const origin = c.req.header('origin');
9040
9191
  const site = c.req.header('sec-fetch-site');
9041
9192
  if ((origin && origin !== new URL(c.req.url).origin) || (site && site !== 'same-origin' && site !== 'none')) {
9042
- throw new ControlPlaneError(403, 'a store kind release write is not taken from another origin');
9193
+ throw new ControlPlaneError(403, 'a store driver release write is not taken from another origin');
9043
9194
  }
9044
9195
  const actor = c.get('actor');
9045
9196
  const person = options.humanStaffActor ? await options.humanStaffActor(c.req.raw) : null;
9046
9197
  if (!person || person !== actor) {
9047
- throw new ControlPlaneError(403, 'a store kind release is published by a person: this takes a staff sign-in, not a service token');
9198
+ throw new ControlPlaneError(403, 'a store driver release is published by a person: this takes a staff sign-in, not a service token');
9048
9199
  }
9049
9200
  return actor;
9050
9201
  };
9051
9202
  /** The pin a release is run under, or a refusal: the platform runs only what this repo pins. */
9052
9203
  const pinOf = async (c, registration, sha256) => {
9053
- const stored = await c.var.admin.getStoreKindRelease(c.get('actor'), registration.kind, sha256);
9204
+ const stored = await c.var.admin.getStoreDriverRelease(c.get('actor'), registration.driver, sha256);
9054
9205
  if (!stored)
9055
- throw new ControlPlaneError(404, `release ${sha256} of '${registration.kind}' is not stored`);
9206
+ throw new ControlPlaneError(404, `release ${sha256} of '${registration.driver}' is not stored`);
9056
9207
  const pin = registration.releases.find((p) => p.sha256 === sha256);
9057
9208
  if (!pin) {
9058
- throw new ControlPlaneError(422, `release ${sha256} of '${registration.kind}' is not pinned in its registration; it runs once a reviewed change pins it and the control plane is deployed`);
9209
+ throw new ControlPlaneError(422, `release ${sha256} of '${registration.driver}' is not pinned in its registration; it runs once a reviewed change pins it and the control plane is deployed`);
9059
9210
  }
9060
9211
  if (pin.sourceCommit !== stored.sourceCommit) {
9061
9212
  throw new ControlPlaneError(422, `release ${sha256} is pinned at ${pin.sourceCommit} and was stored as built from ${stored.sourceCommit}`);
@@ -9066,7 +9217,7 @@ export function createControlPlaneApi(options) {
9066
9217
  const { flow, ensureScopeStores } = releaseFlow();
9067
9218
  const actor = c.get('actor');
9068
9219
  const loadRelease = storedReleaseLoader({ admin: c.var.admin, actor, bytes: flow.bytes });
9069
- const result = await runStoreKindRollout({
9220
+ const result = await runStoreDriverRollout({
9070
9221
  admin: c.var.admin,
9071
9222
  actor,
9072
9223
  registration,
@@ -9076,61 +9227,138 @@ export function createControlPlaneApi(options) {
9076
9227
  const result = await ensureScopeStores({ ...input, loadRelease });
9077
9228
  return result ? result.uploaded > 0 : true;
9078
9229
  },
9079
- ensureCanary: (to) => flow.ensureCanary({ kind: registration.kind, to, loadRelease }),
9230
+ ensureCanary: (to) => flow.ensureCanary({ driver: registration.driver, to, loadRelease }),
9080
9231
  callCanary: flow.callCanary,
9081
9232
  });
9082
9233
  return c.json(result, result.complete ? 200 : 202);
9083
9234
  };
9084
- /** Neither a rollout nor a rollback lowers the stored schema: a kind migrates forward only. */
9235
+ /** Neither a rollout nor a rollback lowers the stored schema: a driver migrates forward only. */
9085
9236
  const assertSchemaKept = (registration, from, to) => {
9086
9237
  if (from === null || from === to.sha256)
9087
9238
  return;
9088
9239
  const leaving = registration.releases.find((p) => p.sha256 === from);
9089
9240
  if (!leaving)
9090
- throw new ControlPlaneError(409, `the current release ${from} of '${registration.kind}' is not pinned, so its schema cannot be judged`);
9241
+ throw new ControlPlaneError(409, `the current release ${from} of '${registration.driver}' is not pinned, so its schema cannot be judged`);
9091
9242
  if (to.schema < leaving.schema) {
9092
9243
  throw new ControlPlaneError(409, `release ${to.sha256} has schema ${to.schema}, below the current release's ${leaving.schema}: objects may already have migrated`);
9093
9244
  }
9094
9245
  };
9095
- app.get('/store-kinds/:kind', async (c) => {
9096
- const registration = registeredKind(c.req.param('kind'));
9097
- const state = await c.var.admin.storeKindState(c.get('actor'), registration.kind);
9098
- return c.json({ kind: registration.kind, pins: registration.releases, ...state });
9246
+ /** Where the platform's rebuild of every script of the driver stands (#2309), if it rebuilds them. */
9247
+ const rebuildView = async (admin, actor, driver, known) => {
9248
+ const fingerprint = known ?? (await options.storeReleases?.fingerprint?.());
9249
+ if (!fingerprint)
9250
+ return undefined;
9251
+ const [walk, declared] = await Promise.all([admin.storeDriverRebuild(actor, driver), admin.storeDriverDeclarations(actor, driver)]);
9252
+ const verticals = [...new Set([...declared.verticals.map((v) => v.vertical), ...declared.unreadable])].sort();
9253
+ return { fingerprint, ...storeRebuildStatus(walk, fingerprint, verticals, options.storeReleases?.settleMs), walk: walk ?? null };
9254
+ };
9255
+ app.get('/store-drivers/:driver', async (c) => {
9256
+ const registration = registeredDriver(c.req.param('driver'));
9257
+ const state = await c.var.admin.storeDriverState(c.get('actor'), registration.driver);
9258
+ const rebuild = await rebuildView(c.var.admin, c.get('actor'), registration.driver);
9259
+ return c.json({ driver: registration.driver, pins: registration.releases, ...state, ...(rebuild ? { rebuild } : {}) });
9099
9260
  });
9100
9261
  // Store a release's bytes, before or after its pin lands (D-63). Idempotent per bundle.
9101
- app.put('/store-kinds/:kind/releases', async (c) => {
9102
- const registration = registeredKind(c.req.param('kind'));
9262
+ app.put('/store-drivers/:driver/releases', async (c) => {
9263
+ const registration = registeredDriver(c.req.param('driver'));
9103
9264
  const actor = await byAPerson(c);
9104
9265
  const { flow } = releaseFlow();
9105
9266
  const body = await readCappedJson(c.req.raw, STORE_RELEASE_REQUEST_MAX_BYTES);
9106
- const release = await storeKindRelease({ admin: c.var.admin, actor, bytes: flow.bytes, kind: registration.kind, body });
9267
+ const release = await storeDriverRelease({ admin: c.var.admin, actor, bytes: flow.bytes, driver: registration.driver, body });
9107
9268
  return c.json({ release, pinned: registration.releases.some((p) => p.sha256 === release.sha256 && p.sourceCommit === release.sourceCommit) }, 201);
9108
9269
  });
9109
9270
  // Roll a stored, pinned release out: the canary, then every vertical that ever declared the
9110
- // kind. Posting the same release again converges a rollout that stopped.
9111
- app.post('/store-kinds/:kind/rollouts', async (c) => {
9112
- const registration = registeredKind(c.req.param('kind'));
9271
+ // driver. Posting the same release again converges a rollout that stopped.
9272
+ app.post('/store-drivers/:driver/rollouts', async (c) => {
9273
+ const registration = registeredDriver(c.req.param('driver'));
9113
9274
  await byAPerson(c);
9114
9275
  const { sha256 } = z.object({ sha256: z.string().regex(/^[0-9a-f]{64}$/) }).strict().parse(await c.req.json());
9115
9276
  const pin = await pinOf(c, registration, sha256);
9116
- const { current } = await c.var.admin.storeKindState(c.get('actor'), registration.kind);
9277
+ const { current } = await c.var.admin.storeDriverState(c.get('actor'), registration.driver);
9117
9278
  assertSchemaKept(registration, current?.sha256 ?? null, pin);
9118
9279
  return rollOut(c, registration, sha256, 'rollout');
9119
9280
  });
9120
9281
  // Return to the immediately previous good release (D-63), never further and never a guess. After
9121
9282
  // a rollout that ended diverged, that is the current good release itself: undoing the bad
9122
9283
  // rollout converges every script back on the release the fleet was healthy on (kernel
9123
- // `storeKindStateIn`, `rollbackTarget`).
9124
- app.post('/store-kinds/:kind/rollback', async (c) => {
9125
- const registration = registeredKind(c.req.param('kind'));
9284
+ // `storeDriverStateIn`, `rollbackTarget`).
9285
+ app.post('/store-drivers/:driver/rollback', async (c) => {
9286
+ const registration = registeredDriver(c.req.param('driver'));
9126
9287
  await byAPerson(c);
9127
- const { current, rollbackTarget } = await c.var.admin.storeKindState(c.get('actor'), registration.kind);
9288
+ const { current, rollbackTarget } = await c.var.admin.storeDriverState(c.get('actor'), registration.driver);
9128
9289
  if (!current || !rollbackTarget)
9129
- throw new ControlPlaneError(409, `kind '${registration.kind}' has no previous good release to roll back to`);
9290
+ throw new ControlPlaneError(409, `driver '${registration.driver}' has no previous good release to roll back to`);
9130
9291
  const pin = await pinOf(c, registration, rollbackTarget);
9131
9292
  assertSchemaKept(registration, current.sha256, pin);
9132
9293
  return rollOut(c, registration, rollbackTarget, 'rollback');
9133
9294
  });
9295
+ // Run a pass of the platform's rebuild of every script of the driver now (#2309): the scheduled
9296
+ // pass does the same every quarter hour, and this is for when that is too slow, after a key is
9297
+ // revoked. Same lease and hold as the scheduled pass, so the two never walk the driver at once,
9298
+ // and a rollout or rollback excludes both. Rebuilds at the release each script runs; moves none.
9299
+ app.post('/store-drivers/:driver/rebuild', async (c) => {
9300
+ const registration = registeredDriver(c.req.param('driver'));
9301
+ const actor = await byAPerson(c);
9302
+ // `deployFinished`: the person attests that no control-plane deploy is rolling out, so the walk
9303
+ // this run starts proves every script current once done, without waiting out the settle window.
9304
+ const text = await c.req.text();
9305
+ let raw = {};
9306
+ if (text.trim() !== '') {
9307
+ try {
9308
+ raw = JSON.parse(text);
9309
+ }
9310
+ catch {
9311
+ throw new ControlPlaneError(422, 'the body is not JSON');
9312
+ }
9313
+ }
9314
+ const body = z.object({ deployFinished: z.boolean().optional() }).strict().parse(raw);
9315
+ const { flow, ensureScopeStores } = releaseFlow();
9316
+ if (!flow.fingerprint)
9317
+ throw new ControlPlaneError(501, 'this control plane does not rebuild store scripts');
9318
+ let fingerprint;
9319
+ let ran = {
9320
+ driver: registration.driver,
9321
+ outcome: 'error',
9322
+ walked: [],
9323
+ uploaded: [],
9324
+ ...(body.deployFinished ? { deployFinished: true } : {}),
9325
+ };
9326
+ try {
9327
+ // Inside the audited path: lists that cannot be read refuse the run, and that refusal is audited too.
9328
+ fingerprint = await flow.fingerprint();
9329
+ const [pass] = await runStoreFleetRebuild({
9330
+ admin: c.var.admin,
9331
+ actor,
9332
+ drivers: [registration],
9333
+ fingerprint,
9334
+ bytes: flow.bytes,
9335
+ ensure: ensureScopeStores,
9336
+ ensureCanary: flow.ensureCanary,
9337
+ budget: storeRebuildBudget(STORE_REBUILD_REQUEST_SUBREQUESTS),
9338
+ // A person asking re-walks a driver that is done: after a control plane deploy has finished
9339
+ // rolling out, that walk is the proof no script kept the older lists.
9340
+ reverify: 'always',
9341
+ ...(flow.settleMs === undefined ? {} : { settleMs: flow.settleMs }),
9342
+ ...(body.deployFinished ? { attestedBy: actor } : {}),
9343
+ });
9344
+ const { rebuild: _row, ...result } = pass;
9345
+ ran = { ...ran, ...result };
9346
+ }
9347
+ catch (e) {
9348
+ ran = { ...ran, error: e instanceof Error ? e.message : String(e) };
9349
+ throw e;
9350
+ }
9351
+ finally {
9352
+ // Written however the pass ended, a refused one included: a staff request reached the fleet.
9353
+ await c.var.admin.recordStoreDriverRebuildRun(actor, ran);
9354
+ }
9355
+ if (ran.outcome === 'error')
9356
+ throw new ControlPlaneError(502, `the rebuild of '${registration.driver}' failed: ${ran.error}`);
9357
+ const rebuild = (await rebuildView(c.var.admin, actor, registration.driver, fingerprint));
9358
+ // Scripts remain, or another holder has the driver: post again.
9359
+ const again = ran.outcome === 'paused' || ran.outcome === 'held' || ran.outcome === 'busy';
9360
+ return c.json({ pass: ran, rebuild }, again ? 202 : 200);
9361
+ });
9134
9362
  return app;
9135
9363
  }
9136
9364
  /**