@substrat-run/control-plane-api 0.176.0 → 0.177.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 (55) hide show
  1. package/dist/api.d.ts +11 -0
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +392 -54
  4. package/dist/api.js.map +1 -1
  5. package/dist/backups.d.ts +51 -9
  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/index.d.ts +4 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/migration-extract.d.ts +92 -0
  28. package/dist/migration-extract.d.ts.map +1 -0
  29. package/dist/migration-extract.js +217 -0
  30. package/dist/migration-extract.js.map +1 -0
  31. package/dist/platform-entry.d.ts +2 -0
  32. package/dist/platform-entry.d.ts.map +1 -1
  33. package/dist/platform-entry.js +2 -1
  34. package/dist/platform-entry.js.map +1 -1
  35. package/dist/platform-scope-stores.d.ts +9 -11
  36. package/dist/platform-scope-stores.d.ts.map +1 -1
  37. package/dist/platform-scope-stores.js +34 -19
  38. package/dist/platform-scope-stores.js.map +1 -1
  39. package/dist/r2-backups.d.ts +8 -5
  40. package/dist/r2-backups.d.ts.map +1 -1
  41. package/dist/r2-backups.js +109 -36
  42. package/dist/r2-backups.js.map +1 -1
  43. package/dist/seal.d.ts +5 -2
  44. package/dist/seal.d.ts.map +1 -1
  45. package/dist/seal.js +3 -0
  46. package/dist/seal.js.map +1 -1
  47. package/dist/staff-client.d.ts +10 -1
  48. package/dist/staff-client.d.ts.map +1 -1
  49. package/dist/staff-client.js +4 -2
  50. package/dist/staff-client.js.map +1 -1
  51. package/dist/vertical-client.d.ts +6 -0
  52. package/dist/vertical-client.d.ts.map +1 -1
  53. package/dist/vertical-client.js +16 -0
  54. package/dist/vertical-client.js.map +1 -1
  55. package/package.json +8 -8
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,6 +70,7 @@ 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';
@@ -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
@@ -5346,20 +5396,67 @@ export function createControlPlaneApi(options) {
5346
5396
  `store is global, so a backup would move its data out of that jurisdiction ` +
5347
5397
  `(K-32); refused until a per-jurisdiction store exists`);
5348
5398
  }
5349
- const dump = await c.var.admin.exportScope(c.get('actor'), tenantId, scope.id);
5399
+ const { tables: hostTables, ...header } = await c.var.admin.exportScope(c.get('actor'), tenantId, scope.id);
5350
5400
  const vertical = await verticalForScope(c, scope);
5351
- const tables = vertical ? await vertical.exportScope(scope.id) : dump.tables;
5352
5401
  // Store tables are opaque and stay unsealed. The platform reads them from the store script
5353
5402
  // (#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 } });
5403
+ const stores = (await exportPlatformStores(c, scope)) ?? header.stores;
5404
+ const backup = backupStage(store.begin({ vertical: scope.vertical, header: { ...header, stores } }), sealerFor(c, tenantId, scope.id));
5405
+ // #2839: from a vertical, the data moves into the store a page at a time, as a paged copy
5406
+ // moves it into another scope (#2450): captured, so a scope taking writes meanwhile is still
5407
+ // copied as it was at one moment, and never held whole here. A deployment that predates
5408
+ // paging answers whole, and that is stored as one entry, as the co-located host's tables are.
5409
+ const moved = vertical
5410
+ ? (await transferScope(vertical, backup.dest, { from: scope.id }, { retry: retryTransient, stamp: false, alwaysStage: true })).dump
5411
+ : hostTables;
5412
+ if (Array.isArray(moved))
5413
+ await backup.dest.stageScopePage(scope.id, ulid(), 0, moved);
5414
+ try {
5415
+ return await backup.commit();
5416
+ }
5417
+ catch (e) {
5418
+ await backup.discard();
5419
+ throw e;
5420
+ }
5421
+ };
5422
+ /**
5423
+ * A backup being written, as the destination of a paged copy (#2839): each page and each batch of
5424
+ * captured changes the copy stages becomes one entry, sealed per subject on the way in (#37).
5425
+ * This copy is full-fidelity and the platform keeps it, which is precisely why an erasure could
5426
+ * never reach it: a redaction fixes the live scope and does nothing to the object already in R2.
5427
+ * Sealed here, destroying one subject's key reaches backwards into every copy already taken.
5428
+ * Restores open it again, except for subjects shredded in the meantime, which is the mechanism
5429
+ * working rather than the copy being damaged.
5430
+ *
5431
+ * A copy that starts again discards its stage and stages anew, so the backup is the stage written
5432
+ * last.
5433
+ */
5434
+ const backupStage = (writer, sealer) => {
5435
+ let last = { stageId: '', entries: 0, tables: new Set() };
5436
+ const took = (stageId, seq, tables) => {
5437
+ if (stageId !== last.stageId)
5438
+ last = { stageId, entries: 0, tables: new Set() };
5439
+ last.entries = Math.max(last.entries, seq + 1);
5440
+ for (const t of tables)
5441
+ last.tables.add(t.name);
5442
+ };
5443
+ return {
5444
+ dest: {
5445
+ stageScopePage: async (_, stageId, seq, tables) => {
5446
+ await writer.put(stageId, seq, { kind: 'page', tables: await sealDump(tables, sealer) });
5447
+ took(stageId, seq, tables);
5448
+ return { staged: true };
5449
+ },
5450
+ stageScopeChanges: async (_, stageId, seq, tables) => {
5451
+ await writer.put(stageId, seq, { kind: 'changes', tables: await sealDump(tables, sealer) });
5452
+ took(stageId, seq, tables);
5453
+ return { staged: true };
5454
+ },
5455
+ discardStagedScope: (_, stageId) => writer.discard(stageId),
5456
+ },
5457
+ commit: () => writer.commit({ stageId: last.stageId, entries: last.entries, tables: last.tables.size }),
5458
+ discard: () => writer.discard(last.stageId),
5459
+ };
5363
5460
  };
5364
5461
  // The copies held for one scope — metadata only, so listing a reaped scope's backups
5365
5462
  // is cheap and hands out no bytes. Readable AFTER the reap (that is the point): the
@@ -5377,9 +5474,11 @@ export function createControlPlaneApi(options) {
5377
5474
  return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5378
5475
  return c.json(await options.scopeBackups.list({ tenantId, scopeId }));
5379
5476
  });
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.
5477
+ // One backup's DUMP. Staff-only like the export it came from (not in BUILDER_ROUTES), and
5478
+ // full-fidelity, so it is the same governed-pull posture: K-3 cross-checked above. Only a
5479
+ // backup stored in one entry is served whole (#2839). One stored in several (more pages, or
5480
+ // writes caught while it was taken) is not a dump until a deployment applies them in order,
5481
+ // so it is answered 413 and `POST …/backups/:capturedAt/restore` loads it back.
5383
5482
  app.get('/tenants/:tenantId/scopes/:scopeId/backups/:capturedAt', async (c) => {
5384
5483
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
5385
5484
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
@@ -5389,16 +5488,20 @@ export function createControlPlaneApi(options) {
5389
5488
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
5390
5489
  if (!scope)
5391
5490
  return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5392
- const dump = await options.scopeBackups.get({ tenantId, scopeId, capturedAt });
5393
- if (!dump)
5491
+ const held = await options.scopeBackups.get({ tenantId, scopeId, capturedAt });
5492
+ if (!held)
5394
5493
  return refuse(c, 'not_found', `no backup for scope ${scopeId} at ${capturedAt}`);
5494
+ if (held.entries > 1) {
5495
+ return refuseStatus(c, 413, `backup ${capturedAt} of scope ${scopeId} is stored in ${held.entries} entries (its pages, and any writes caught while it was taken), which only a restore applies; restore it with POST /tenants/${tenantId}/scopes/${scopeId}/backups/${capturedAt}/restore`);
5496
+ }
5497
+ const { tables } = await held.entry(0);
5395
5498
  // Unseal on the way out (#37). The bytes in the store stay sealed — this is the
5396
5499
  // authorized read opening them with the keys the platform holds. A subject shredded
5397
5500
  // since the copy was taken opens to a null payload, which is exactly the erasure
5398
5501
  // working: the ciphertext never left the object, and nothing turns it back into a
5399
5502
  // 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 });
5503
+ const opened = await openDump(mergeDumpPages([tables]), sealerFor(c, tenantId, scopeId));
5504
+ return c.json({ ...held.header, tables: opened });
5402
5505
  });
5403
5506
  // Take a backup WITHOUT reaping — the standalone copy (a pre-migration checkpoint, an
5404
5507
  // export-to-keep). The reap route below takes its own; this is the same act made
@@ -5458,6 +5561,15 @@ export function createControlPlaneApi(options) {
5458
5561
  // version that declares none is not asked. One that cannot be resolved while a driver is
5459
5562
  // registered refuses (503): the key must not be destroyed while a store may hold the subject.
5460
5563
  const reach = await platformStoresOf(c, scope, running);
5564
+ // A store whose driver cannot erase a subject yet would answer `{ erased: 0 }` while it still
5565
+ // holds them. Refused before anything is erased, so the key is kept and a retry can converge.
5566
+ const unerasable = reach?.stores.find((store) => {
5567
+ const registration = (options.storeDrivers ?? STORE_DRIVERS).find((k) => k.driver === store.driver);
5568
+ return registration !== undefined && !storeDriverErasesSubjects(registration);
5569
+ });
5570
+ if (unerasable) {
5571
+ throw new ControlPlaneError(501, `scope ${scopeId}'s store '${unerasable.binding}' runs driver '${unerasable.driver}', which cannot erase a subject yet; nothing was erased`);
5572
+ }
5461
5573
  /**
5462
5574
  * L19: a bind, promote, rollback or serving re-upload that lands while the stores are erased
5463
5575
  * would run a version whose stores this erasure did not reach. Both arms take one atomic claim
@@ -6232,32 +6344,135 @@ export function createControlPlaneApi(options) {
6232
6344
  // keyed by (scope, subject), so a copy of scope A landing in scope B — a backout onto
6233
6345
  // a different scope, a world loaded sideways — must be opened with A's keys. Using
6234
6346
  // 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);
6347
+ const tables = await openDump(dump.tables, sealerFor(c, tenantIdSchema.parse(dump.tenantId), scopeIdSchema.parse(dump.scopeId)));
6348
+ return c.json(await loadIntoScope(c, scope, { header: dump, tables, sourceHint }));
6349
+ }
6350
+ catch (e) {
6351
+ return restoreFailed(c, scope, e);
6352
+ }
6353
+ });
6354
+ // Restore a held backup into its scope, read from the store here rather than handed in (#2839):
6355
+ // a backup stored in parts is staged at the deployment one entry at a time, opened with the
6356
+ // backup's own provenance, and loaded from the stage, so no call holds the whole backup. One
6357
+ // stored whole loads as `POST …/restore` loads a dump, through the same path. Staff-only, as the
6358
+ // backup routes are (not in TENANT_ROUTES or BUILDER_ROUTES).
6359
+ app.post('/tenants/:tenantId/scopes/:scopeId/backups/:capturedAt/restore', async (c) => {
6360
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
6361
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
6362
+ const capturedAt = c.req.param('capturedAt');
6363
+ const store = options.scopeBackups;
6364
+ if (!store)
6365
+ return refuseStatus(c, 501, 'no backup target configured');
6366
+ const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
6367
+ if (!scope)
6368
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
6369
+ const held = await store.get({ tenantId, scopeId, capturedAt });
6370
+ if (!held)
6371
+ return refuse(c, 'not_found', `no backup for scope ${scopeId} at ${capturedAt}`);
6372
+ const { header } = held;
6373
+ if (options.platformScopeStores) {
6374
+ try {
6375
+ await refuseUndeclaredDumpStores(c, scope, header.stores);
6376
+ }
6377
+ catch (e) {
6378
+ if (e instanceof ControlPlaneError)
6379
+ return relayRefusal(c, e);
6380
+ throw e;
6381
+ }
6382
+ }
6383
+ const sealer = sealerFor(c, tenantIdSchema.parse(header.tenantId), scopeIdSchema.parse(header.scopeId));
6384
+ try {
6385
+ if (held.entries === 1) {
6386
+ const { tables } = await held.entry(0);
6387
+ if (tables.length === 0) {
6388
+ return refuseStatus(c, 422, 'restore refused: the backup has no tables — not a scope dump');
6389
+ }
6390
+ return c.json(await loadIntoScope(c, scope, { header, tables: await openDump(mergeDumpPages([tables]), sealer) }));
6391
+ }
6392
+ // The host judges the roles the backup seats or defines before anything is replaced (#2555), and
6393
+ // records the tables it restores. So it is handed every table's schema and the role tables' rows,
6394
+ // gathered while the entries are staged: its whole part of a load into a deployment, whose
6395
+ // placeholder is not the scope's store.
6396
+ const stage = async (vertical) => {
6397
+ const stageId = ulid();
6398
+ const judged = rolesJudgedOf();
6399
+ try {
6400
+ for (let index = 0; index < held.entries; index++) {
6401
+ const entry = await held.entry(index);
6402
+ let staged;
6403
+ if (entry.kind === 'page') {
6404
+ const tables = await openDump(entry.tables, sealer);
6405
+ judged.page(tables);
6406
+ staged = await retryTransient(() => vertical.stageScopePage(scopeId, stageId, index, tables));
6407
+ }
6408
+ else {
6409
+ const tables = await openDump(entry.tables, sealer);
6410
+ judged.changes(tables);
6411
+ staged = await retryTransient(() => vertical.stageScopeChanges(scopeId, stageId, index, tables));
6412
+ }
6413
+ if (staged === 'predates') {
6414
+ 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`);
6415
+ }
6416
+ }
6417
+ }
6418
+ catch (e) {
6419
+ await abandonCopy({ dest: vertical }, { from: scopeId, into: scopeId, stageId });
6420
+ throw e;
6421
+ }
6422
+ return { dump: { stageId }, tables: judged.tables() };
6423
+ };
6424
+ return c.json(await loadIntoScope(c, scope, { header, tables: [], stage }));
6425
+ }
6426
+ catch (e) {
6427
+ return restoreFailed(c, scope, e);
6428
+ }
6429
+ });
6430
+ /**
6431
+ * Load a dump into an existing scope: the one path `POST …/restore` and a backup's restore share,
6432
+ * so both write the same audit row, judge the same roles and stores, replay the same erasures and
6433
+ * clear the same receipt. `tables` are opened (#37). `stage`, for a backup stored in several
6434
+ * entries (#2839), stages it at the scope's deployment, which then loads the stage, and answers
6435
+ * the tables the host's audited restore judges and records in place of `tables`. Those are every
6436
+ * table's schema with the role rows only, so after a staged restore the host's placeholder holds
6437
+ * no data, and its `_substrat_migrations` and `_substrat_meta` journals are empty, where a whole
6438
+ * dump's restore leaves them full. The scope's own journals are the deployment's.
6439
+ */
6440
+ const loadIntoScope = async (c, scope, input) => {
6441
+ const actor = c.get('actor');
6442
+ const tenantId = tenantIdSchema.parse(scope.tenantId);
6443
+ const scopeId = scopeIdSchema.parse(scope.id);
6444
+ const { header, sourceHint } = input;
6445
+ const origin = scopeIdSchema.parse(header.scopeId);
6446
+ const vertical = await verticalForScope(c, scope);
6447
+ if (input.stage && !vertical) {
6448
+ throw new ControlPlaneError(409, `scope ${scopeId} has no deployment to load a backup stored in parts into`);
6449
+ }
6450
+ // #2185: cleared before the load as well as after it (below): a step after the load can still
6451
+ // throw (the deployment's half, the re-assert), and the data that landed may predate a reconcile.
6452
+ await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null);
6453
+ const staged = input.stage && vertical ? await input.stage(vertical) : undefined;
6454
+ const tables = staged?.tables ?? input.tables;
6455
+ try {
6238
6456
  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);
6457
+ const landing = { ...header, tables, stores: storesCaller && vertical ? [] : header.stores };
6243
6458
  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) {
6459
+ if (storesCaller && vertical && scope.vertical && header.stores.length > 0) {
6245
6460
  await storesCaller.importStores({
6246
6461
  tenantId,
6247
6462
  scopeId,
6248
6463
  vertical: scope.vertical,
6249
- stores: dump.stores,
6464
+ stores: header.stores,
6250
6465
  });
6251
6466
  }
6252
6467
  const platformReplay = storesCaller && scope.vertical
6253
- ? await replayPlatformStoreErasures(c, scope, sourceHint ?? origin.scopeId)
6468
+ ? await replayPlatformStoreErasures(c, scope, sourceHint ?? origin)
6254
6469
  : undefined;
6255
6470
  const replays = [hostReplay, platformReplay].filter((r) => !!r);
6256
6471
  // #1742: the recorded OFF positions ride the restore, so the deployment switches them
6257
6472
  // off in the replay's own event — its own sweeper cannot land in between.
6258
6473
  const restored = vertical
6259
- ? await restoreCarryingSwitches(actor, vertical, tenantId, scopeId, tables, {
6260
- scopeId: sourceHint ?? origin.scopeId,
6474
+ ? await restoreCarryingSwitches(actor, vertical, tenantId, scopeId, staged?.dump ?? tables, {
6475
+ scopeId: sourceHint ?? origin,
6261
6476
  exact: false,
6262
6477
  })
6263
6478
  : undefined;
@@ -6271,31 +6486,37 @@ export function createControlPlaneApi(options) {
6271
6486
  // finished: the install reads as not reconciled, and the sweep's next pass reconciles it.
6272
6487
  // Again here, after the load, for a sweep reconcile that wrote the receipt while it ran.
6273
6488
  await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null);
6274
- return c.json({
6489
+ return {
6275
6490
  restored: scopeId,
6276
6491
  tables: tables.length,
6277
6492
  // #2820: what the deployment serving the scope did with the dump's own indexes and triggers.
6278
6493
  ...(restored ? { moduleObjects: restored.moduleObjects } : {}),
6279
6494
  ...storeReplayFields(replays),
6280
- });
6495
+ };
6281
6496
  }
6282
6497
  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 });
6498
+ // A stage the refused load left at the deployment goes now rather than waiting for a wipe.
6499
+ if (staged && vertical)
6500
+ await abandonCopy({ dest: vertical }, { from: scopeId, into: scopeId, stageId: staged.dump.stageId });
6501
+ throw e;
6297
6502
  }
6298
- });
6503
+ };
6504
+ /** How a refused or failed restore answers, on either route. */
6505
+ const restoreFailed = async (c, scope, e) => {
6506
+ // #2185: and once more on the way out of a failure, for a sweep reconcile that wrote the
6507
+ // receipt between the clear before the load and the throw. Clearing is always safe (at worst
6508
+ // one more reconcile); a clear that fails here must not hide the restore's own error.
6509
+ await c.var.admin.markScopeProvisioned(c.get('actor'), tenantIdSchema.parse(scope.tenantId), scopeIdSchema.parse(scope.id), null).catch(() => undefined);
6510
+ if (e instanceof ControlPlaneError) {
6511
+ return relayRefusal(c, e);
6512
+ }
6513
+ // A restore throw is driven by the dump (a shape the target cannot load, a DDL the engine
6514
+ // rejects), so DISCLOSE it as an actionable 422 rather than collapsing to the generic 500
6515
+ // `internal error` mapError would produce (#321, secondary obs #2). The routes are staff- or
6516
+ // own-tenant-gated and the detail is about the dump, not another tenant's state.
6517
+ const detail = e instanceof Error ? e.message : String(e);
6518
+ return refuseStatus(c, 422, 'restore failed — the backup could not be loaded', { detail });
6519
+ };
6299
6520
  // #1236: when this scope's migrations actually ran — the schema-change history
6300
6521
  // whose instants `_substrat_migrations.applied_at` has held all along with no
6301
6522
  // reader. Read through the vertical that holds the scope's data when one
@@ -7322,6 +7543,7 @@ export function createControlPlaneApi(options) {
7322
7543
  return refuse(c, 'forbidden', 'forbidden');
7323
7544
  }
7324
7545
  input.verticalSlug = slug;
7546
+ let scopeStores = [];
7325
7547
  // `manifestJson` is a string the schema cannot look inside, and every reader parses it
7326
7548
  // with `storedDeployManifest` and 500s on a row it refuses: the per-version reads below
7327
7549
  // (registry, schedules, flow, model, assets), the serving-upload rebuild that promote
@@ -7367,7 +7589,12 @@ export function createControlPlaneApi(options) {
7367
7589
  const connectorProblems = pushedConnectorProblems(checked.data);
7368
7590
  if (connectorProblems.length > 0)
7369
7591
  return refuseManifest({ path: ['connectors'], message: connectorProblems.join('; ') });
7592
+ scopeStores = checked.data.scopeStores;
7370
7593
  }
7594
+ // #2210: publishing records the version's store bindings for the whole vertical's lifecycle
7595
+ // (backup, wipe, erasure, the meter), so its store scripts are put in place first, as a push
7596
+ // does: a driver with no approved release refuses here, and nothing is published.
7597
+ await ensureScopeStoresOf(c.get('actor'), 'deploy', slug, scopeStores, async () => (await ownerOf(c.get('actor'), slug)) ?? null);
7371
7598
  await c.var.admin.publishVersion(c.get('actor'), input);
7372
7599
  const version = await c.var.admin.getVersion(c.get('actor'), input.id, slug);
7373
7600
  return c.json(version, 201);
@@ -7425,7 +7652,11 @@ export function createControlPlaneApi(options) {
7425
7652
  base: base ?? null,
7426
7653
  digest: await promoteReviewDigest(baseVersion && { ...baseVersion, migrations: baseline ?? null }, { ...incomingVersion, migrations: incoming }),
7427
7654
  };
7428
- return c.json({ migrations: incoming === null ? null : migrationsOnTop(incoming, baseline), review });
7655
+ // #2351: whether the incoming set was read from the version's bundle, or only declared by its
7656
+ // push (pushed before the read, or a bundle that exposes no registrations). A version storing none was
7657
+ // compared with nothing, whatever it recorded.
7658
+ const readFromBundle = incoming !== null && !!(await c.var.admin.versionMigrationsExtracted?.(c.get('actor'), slug, incomingId));
7659
+ return c.json({ migrations: incoming === null ? null : migrationsOnTop(incoming, baseline), review, readFromBundle });
7429
7660
  });
7430
7661
  // The emitted entity model (#1214) one version ships: entities, field schemas, parent
7431
7662
  // edges, declared lifecycles — read out of the same retained manifest as the registry
@@ -7665,6 +7896,14 @@ export function createControlPlaneApi(options) {
7665
7896
  const ensureScopeStoresOf = async (actor, operation, slug, stores, owner) => {
7666
7897
  if (stores.length === 0)
7667
7898
  return;
7899
+ // D-67: a driver whose registration names its verticals serves those only, refused before any
7900
+ // hold is taken or anything is uploaded.
7901
+ for (const store of stores) {
7902
+ const registration = (options.storeDrivers ?? STORE_DRIVERS).find((k) => k.driver === store.driver);
7903
+ if (registration && !storeDriverServesVertical(registration, slug)) {
7904
+ throw new ControlPlaneError(403, `${operation} refused: scope store driver '${store.driver}' does not serve vertical '${slug}'`);
7905
+ }
7906
+ }
7668
7907
  const holder = ulid();
7669
7908
  const drivers = [...new Set(stores.map((store) => store.driver))].sort();
7670
7909
  const held = [];
@@ -7752,6 +7991,59 @@ export function createControlPlaneApi(options) {
7752
7991
  recordFailure({ actor, operation: 'deploy.wire-record', vertical, version: versionId, message: `the capability record of ${deploymentRef} was not written` }, error);
7753
7992
  }
7754
7993
  };
7994
+ const readBundleMigrations = async (bundle, declared) => {
7995
+ if (!options.extractMigrations)
7996
+ return { kind: 'unmarked' };
7997
+ let registered;
7998
+ try {
7999
+ registered = await options.extractMigrations(bundle);
8000
+ }
8001
+ catch (e) {
8002
+ if (e instanceof MigrationLoaderUnavailable)
8003
+ return { kind: 'unavailable', message: e.message };
8004
+ if (!(e instanceof MigrationExtractionError))
8005
+ throw e;
8006
+ return { kind: 'failed', message: e.message };
8007
+ }
8008
+ if (!registered)
8009
+ return { kind: 'unmarked' };
8010
+ const problem = declared && extractedMigrationsProblem(declared, registered);
8011
+ if (problem)
8012
+ return { kind: 'differs', message: extractedMigrationsRefusal(problem) };
8013
+ return { kind: 'read', digest: await extractedMigrationsDigest(registered) };
8014
+ };
8015
+ /**
8016
+ * #2351: a version pushed before its bundle was read is read once, at its first promote, from
8017
+ * its archive script. Same set: recorded, and the promote goes on. Another set: the refusal,
8018
+ * before anything moves. No registrations, or a bundle that cannot be read here (it was
8019
+ * accepted at push, before this read existed): the promote goes on as it did, unrecorded, so such
8020
+ * a version is read again at every promote. A version storing no migrations has nothing to
8021
+ * compare, and one that is not admitted is refused by the promote without a read.
8022
+ */
8023
+ const bundleMigrationsRefusal = async (admin, actor, slug, versionId) => {
8024
+ if (!options.extractMigrations || !options.fetchVerticalModules || !admin.versionMigrationsExtracted || !admin.recordMigrationsExtracted) {
8025
+ return null;
8026
+ }
8027
+ const [extracted, declared, version, manifestJson] = await Promise.all([
8028
+ admin.versionMigrationsExtracted(actor, slug, versionId),
8029
+ admin.versionMigrations(actor, slug, versionId),
8030
+ admin.getVersion(actor, versionId, slug),
8031
+ admin.versionManifest(actor, slug, versionId),
8032
+ ]);
8033
+ if (extracted !== null || declared === null || version?.admission !== 'admitted' || !version.deploymentRef || !manifestJson)
8034
+ return null;
8035
+ const manifest = storedDeployManifest.parse(JSON.parse(manifestJson));
8036
+ // An archive that cannot be read back is the serve's to report, after the promote, as it is today.
8037
+ const modules = await options.fetchVerticalModules(version.deploymentRef).catch(() => null);
8038
+ if (!modules)
8039
+ return null;
8040
+ const read = await readBundleMigrations({ entry: manifest.entry, compatibilityDate: manifest.compatibilityDate, compatibilityFlags: manifest.compatibilityFlags, modules }, declared);
8041
+ if (read.kind === 'differs')
8042
+ return read.message;
8043
+ if (read.kind === 'read')
8044
+ await admin.recordMigrationsExtracted(actor, slug, versionId, read.digest);
8045
+ return null;
8046
+ };
7755
8047
  /**
7756
8048
  * Serve a version IN PLACE (#286): upload its bundle onto the vertical's ONE stable
7757
8049
  * serving script, where every scope's data DO lives — this is the step that makes a
@@ -8144,6 +8436,10 @@ export function createControlPlaneApi(options) {
8144
8436
  const answer = roleStateAnswer(roles, confinedTenant(p));
8145
8437
  if (answer)
8146
8438
  return refuseStatus(c, answer.status, answer.message, answer.extra);
8439
+ // #2351: a version whose bundle was never read is read now, before anything moves.
8440
+ const bundleRefusal = await bundleMigrationsRefusal(c.var.admin, c.get('actor'), slug, versionId);
8441
+ if (bundleRefusal)
8442
+ return refuseStatus(c, 422, bundleRefusal);
8147
8443
  // The blast-radius moment: refuses a changed digest without the acknowledgement,
8148
8444
  // and refuses a non-admitted version. Both are enforced below the seam and
8149
8445
  // surface as a 4xx through mapError, not a 500.
@@ -8592,6 +8888,20 @@ export function createControlPlaneApi(options) {
8592
8888
  const sweeper = platformSweeperDecision(manifest, { entry: manifest.entry, modules });
8593
8889
  if ('refuse' in sweeper)
8594
8890
  return refuseStatus(c, 422, sweeper.refuse);
8891
+ // #2351: the migrations the bundle registers, read before anything is uploaded. Where the
8892
+ // manifest carries migrations they must be the registered ones, or the push is refused, and
8893
+ // so is a bundle that cannot be evaluated or does not settle: the stored set is then provably
8894
+ // what the version runs. A declared `[]` is a set like any other: the bundle must register
8895
+ // none. A loader that fails is the platform's, so that push answers 503 and may be retried. A
8896
+ // manifest that leaves the field off (over the caps a manifest carries) stores none, so nothing
8897
+ // is gated: its push is accepted however the read went, and records the digest of what was
8898
+ // read, if anything was.
8899
+ const read = await readBundleMigrations({ entry: manifest.entry, compatibilityDate: manifest.compatibilityDate, compatibilityFlags: manifest.compatibilityFlags, modules }, manifest.migrations ?? null);
8900
+ if (read.kind === 'differs' || (read.kind === 'failed' && manifest.migrations))
8901
+ return refuseStatus(c, 422, read.message);
8902
+ if (read.kind === 'unavailable' && manifest.migrations)
8903
+ return refuseStatus(c, 503, read.message);
8904
+ const migrationsExtracted = read.kind === 'read' ? read.digest : null;
8595
8905
  // Mint the version id first: the deploymentRef (the dispatch script name) is keyed
8596
8906
  // on it, so it is CF-valid and unique per version.
8597
8907
  const id = ulid();
@@ -8709,6 +9019,8 @@ export function createControlPlaneApi(options) {
8709
9019
  manifestJson: JSON.stringify({ ...manifest, platformSweeper: sweeper.supply }),
8710
9020
  });
8711
9021
  await recordWireUpload(c.var.admin, c.get('actor'), id, wire);
9022
+ if (migrationsExtracted)
9023
+ await c.var.admin.recordMigrationsExtracted?.(c.get('actor'), slug, id, migrationsExtracted);
8712
9024
  // Same spirit as the permission-surface gate, advisory tier: when the push DECLARES
8713
9025
  // surfaces, name any surface that hostnames are still bound to but the declaration
8714
9026
  // dropped — the URL keeps resolving (routing never keys on the declaration), it just
@@ -10869,6 +11181,32 @@ export function createControlPlaneApi(options) {
10869
11181
  });
10870
11182
  return c.json(pageOf(entries, filter.limit, (e) => e.id));
10871
11183
  });
11184
+ // -- builder turns: one summary per builder-studio turn, every team -------------
11185
+ // Staff only, and stated here rather than left to the allowlists' default-deny: a
11186
+ // summary names a team and a project, and the turn behind it carries what someone
11187
+ // typed into the studio. The builder answers both; this relays and re-parses.
11188
+ app.get('/builder-turns', async (c) => {
11189
+ if (!staffCaller(c))
11190
+ return refuse(c, 'forbidden', 'forbidden');
11191
+ if (!options.builderTurns)
11192
+ return refuseStatus(c, 501, 'the builder studio is not configured');
11193
+ const url = new URL(c.req.url);
11194
+ const forwarded = new URLSearchParams();
11195
+ for (const key of ['tenantId', 'projectId', 'outcome', 'redGate', 'troubled', 'since', 'limit', 'cursor']) {
11196
+ const value = url.searchParams.get(key);
11197
+ if (value)
11198
+ forwarded.set(key, value);
11199
+ }
11200
+ return c.json(await options.builderTurns.list(forwarded));
11201
+ });
11202
+ app.get('/builder-turns/:turnId', async (c) => {
11203
+ if (!staffCaller(c))
11204
+ return refuse(c, 'forbidden', 'forbidden');
11205
+ if (!options.builderTurns)
11206
+ return refuseStatus(c, 501, 'the builder studio is not configured');
11207
+ const turn = await options.builderTurns.get(c.req.param('turnId'));
11208
+ return turn ? c.json(turn) : refuse(c, 'not_found', 'no such turn');
11209
+ });
10872
11210
  // -- sweep runs (#1232): the durable record of what a sweep pass touched -------
10873
11211
  // Staff read fleet-wide (the console's later view); a builder's tenant is forced
10874
11212
  // from the principal, the /ops-failures posture exactly.