@substrat-run/control-plane-api 0.121.0 → 0.122.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
package/dist/api.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { resolveObservabilityWindow } from './observability-window.js';
2
2
  import { Hono } from 'hono';
3
- import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ASSET_PART_PREFIX, assetHash, channelName, connectionActivity, connectionActivitySource, connectionCredential, 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, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, peerSwitch, systemSwitch, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, denialGroupBy, registerVerticalInput, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, } from '@substrat-run/contracts';
3
+ import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ASSET_PART_PREFIX, assetHash, channelName, connectionActivity, connectionActivitySource, connectionCredential, 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, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, promotionAcknowledgement, bindAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, peerSwitch, systemSwitch, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, denialGroupBy, registerVerticalInput, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, } from '@substrat-run/contracts';
4
4
  import { attributeFailure } from './failure-attribution.js';
5
- import { crossVerticalHealth, isExportBreakRefusal, migrationProgress, ulid } from '@substrat-run/kernel';
5
+ import { BIND_EXPORT_BREAK_REFUSAL, bindExportBreakRefusal, crossVerticalHealth, exportBreakRefusal, isBindExportBreakRefusal, isExportBreakRefusal, migrationProgress, ulid, } from '@substrat-run/kernel';
6
6
  import { TENANT_HEADER, confinedTenant } from './auth.js';
7
7
  import { mintTenantToken } from './tenant-token.js';
8
8
  import { connectionGrantsForScope } from './vertical-client.js';
@@ -57,6 +57,22 @@ function outsideTenant(p, tenantId) {
57
57
  const pin = confinedTenant(p);
58
58
  return pin !== null && pin !== tenantId;
59
59
  }
60
+ /**
61
+ * #1756: a move refused because it would break apps in the scope's own tenant. Carries the
62
+ * listing, because every route that moves a scope pins that tenant in its path, so the caller may
63
+ * read it whole; the counted sentence is the host's (`bindExportBreakRefusal`).
64
+ */
65
+ class ExportBreakRefused extends ControlPlaneError {
66
+ breaks;
67
+ constructor(breaks) {
68
+ super(409, bindExportBreakRefusal(breaks));
69
+ this.breaks = breaks;
70
+ }
71
+ }
72
+ /** The body a route answers an `ExportBreakRefused` with: the sentence and the listing. */
73
+ function exportBreakBody(e) {
74
+ return { error: e.message, ...(e instanceof ExportBreakRefused ? { exportBreaks: { affected: e.breaks } } : {}) };
75
+ }
60
76
  /**
61
77
  * A fleet-wide report, narrowed to what a caller may see: a confined caller gets its own
62
78
  * tenant's rows and a COUNT of the other tenants, never their ids (K-3's forced filter, held on
@@ -179,6 +195,9 @@ const bindScopeVersionBody = z.object({
179
195
  // data first when this bind crosses a migration-digest boundary. Optional and
180
196
  // ignored on a code-only rebind — the digest compare is the gate, not the flag.
181
197
  snapshot: z.boolean().optional(),
198
+ // #1756: a bind that drops or re-versions an export an app in this tenant imports is
199
+ // refused unless acknowledged — the promote gate's `exportBreak`, for one install.
200
+ acknowledge: bindAcknowledgement.optional(),
182
201
  });
183
202
  const rebindScopeVerticalBody = z.object({
184
203
  // The FULL registry id of the target lineage (e.g. `substrat-9yjbbn/manyfold`) —
@@ -195,7 +214,12 @@ const rebindScopeVerticalBody = z.object({
195
214
  // backout copy exactly as in a carried rebind. The scope must be re-provisioned
196
215
  // on the target afterwards (`/verticals/:slug/instances` is idempotent, K-31).
197
216
  abandonData: z.boolean().optional(),
217
+ // #1756: within one lineage the rebind is an adopt, refused when it breaks an app in the tenant.
218
+ acknowledge: bindAcknowledgement.optional(),
198
219
  });
220
+ // #1756: an adopt moves the scope onto what its vertical serves, and is refused when that breaks
221
+ // an app in the tenant. The body is optional, as it always was.
222
+ const adoptServingBody = z.object({ acknowledge: bindAcknowledgement.optional() });
199
223
  // A snapshot request (preview-and-snapshots.md §3/§9). `expiresAt` opts into the GC
200
224
  // sweep; absent = pinned until deliberately deleted. `kind` defaults to 'archive'.
201
225
  const snapshotScopeBody = z.object({
@@ -815,6 +839,8 @@ export function createControlPlaneApi(options) {
815
839
  if (err instanceof z.ZodError) {
816
840
  return problem(c, { status: 400, body: toProblem(err, c.req.path) });
817
841
  }
842
+ if (err instanceof ExportBreakRefused)
843
+ return c.json(exportBreakBody(err), 409);
818
844
  const { status, body } = mapError(err);
819
845
  // A 5xx is the PLATFORM failing — an unmapped throw, or a downstream vertical's
820
846
  // own 5xx passing through (a DO storage fault during a preview restore is the
@@ -1597,6 +1623,25 @@ export function createControlPlaneApi(options) {
1597
1623
  return (`no deployment is bound for vertical '${slug}': the scope is not on the serving script and its bound ` +
1598
1624
  `version did not resolve. Promote a version to prod, then adopt the scope onto the serving script.`);
1599
1625
  };
1626
+ /**
1627
+ * #1756: refuse, BEFORE any data moves, a move of this scope that would break apps in its
1628
+ * tenant, unless acknowledged. The host refuses the same move whoever calls; this is what keeps
1629
+ * a route that exports, restores or snapshots first from doing so for a move that is refused.
1630
+ * `servingRef` is the route the scope takes in the same act (an adopt).
1631
+ */
1632
+ const refuseOnBreaks = async (actor, tenantId, scopeId, versionId, opts) => {
1633
+ if (opts.acknowledge?.exportBreak)
1634
+ return;
1635
+ const breaks = await admin.bindingImpact(actor, tenantId, scopeId, versionId, opts.servingRef !== undefined ? { servingRef: opts.servingRef } : undefined);
1636
+ if (breaks.length > 0)
1637
+ throw new ExportBreakRefused(breaks);
1638
+ };
1639
+ /** The host's own export-break refusal (the move changed since it was asked), as the same 409. */
1640
+ const relayHostRefusal = (err) => {
1641
+ if (isBindExportBreakRefusal(err))
1642
+ throw new ControlPlaneError(409, err.message);
1643
+ throw err;
1644
+ };
1600
1645
  /**
1601
1646
  * Move ONE legacy scope's data off its per-version dispatch script onto its vertical's
1602
1647
  * stable serving script (#286/#321), then flip routing. The one primitive behind both
@@ -1610,7 +1655,7 @@ export function createControlPlaneApi(options) {
1610
1655
  * installs short-circuit.
1611
1656
  * Throws `ControlPlaneError` so callers surface an actionable status, never a bare 500.
1612
1657
  */
1613
- const adoptScopeOntoServing = async (c, tenantId, scopeId) => {
1658
+ const adoptScopeOntoServing = async (c, tenantId, scopeId, opts = {}) => {
1614
1659
  const actor = c.get('actor');
1615
1660
  const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
1616
1661
  if (!scope) {
@@ -1633,6 +1678,12 @@ export function createControlPlaneApi(options) {
1633
1678
  if (!serving) {
1634
1679
  throw new ControlPlaneError(409, `vertical '${scope.vertical}' has no serving script yet — promote a version to prod first`);
1635
1680
  }
1681
+ // #1756: onto the serving script, the scope runs the served version from the moment routing
1682
+ // flips — whatever its own version exported. Asked before the export below moves anything.
1683
+ await refuseOnBreaks(actor, tenantId, scopeId, serving.versionId, {
1684
+ servingRef: serving.ref,
1685
+ acknowledge: opts.acknowledge,
1686
+ });
1636
1687
  const source = await verticalForScope(c, scope);
1637
1688
  const dest = await options.resolveVerticalRef?.(serving.ref);
1638
1689
  if (!source || !dest) {
@@ -1641,8 +1692,12 @@ export function createControlPlaneApi(options) {
1641
1692
  const dump = await source.exportScope(scopeId);
1642
1693
  const restored = await retryTransient(() => dest.restoreScope(tenantId, scopeId, dump));
1643
1694
  // Data landed — only now flip routing and move the version pointer.
1644
- await admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref);
1645
- await admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId);
1695
+ await admin
1696
+ .setScopeServingRef(actor, tenantId, scopeId, serving.ref, { acknowledge: opts.acknowledge })
1697
+ .catch(relayHostRefusal);
1698
+ await admin
1699
+ .bindScopeVersion(actor, tenantId, scopeId, serving.versionId, { acknowledge: opts.acknowledge })
1700
+ .catch(relayHostRefusal);
1646
1701
  // #1674: the scope now routes to a different store, so put the directory's recorded
1647
1702
  // OFF positions back there — cheap and idempotent when the dump carried them.
1648
1703
  await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
@@ -1661,29 +1716,51 @@ export function createControlPlaneApi(options) {
1661
1716
  * Idempotent and retry-safe: the host cascade never rebound these scopes, so their data
1662
1717
  * is still findable on a retry after a failed serve.
1663
1718
  */
1664
- const adoptAndRebindOwnedScopes = async (c, slug, versionId) => {
1719
+ const adoptAndRebindOwnedScopes = async (c, slug, versionId,
1720
+ // #1756: the promote's own acknowledgement. An install adopted here goes from ITS version to
1721
+ // the promoted one, which the promote gate judged only from the channel's previous version:
1722
+ // an install lagging behind that is judged again, and passes on the promote's ack or not at all.
1723
+ acknowledge) => {
1665
1724
  const actor = c.get('actor');
1725
+ const none = { refused: [] };
1666
1726
  const serving = await admin.verticalServing(actor, slug);
1667
1727
  if (!serving)
1668
- return; // embedded / not dispatch-backed — the host cascade handled rebinds
1728
+ return none; // embedded / not dispatch-backed — the host cascade handled rebinds
1669
1729
  const v = await verticalOf(actor, slug);
1670
1730
  if (!v || v.ownerTenant === null || v.listed)
1671
- return; // private only, like the host cascade
1731
+ return none; // private only, like the host cascade
1672
1732
  const owned = (await admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug, status: ['active'] })).filter((s) => !s.forkedFrom && s.kind !== 'preview');
1733
+ // An install refused for what it would break is left where it is, and the rest still move:
1734
+ // one lagging app must not keep every other install's pointer, or the store backfill after
1735
+ // this, from happening. The caller answers with what was refused.
1736
+ const refused = [];
1737
+ let refusal;
1673
1738
  for (const s of owned) {
1674
- if (!s.servingRef) {
1675
- // Adopt: export from the scope's current (un-rebound) dispatch → serving script,
1676
- // then bind to the serving version. Data-first, so a failure here leaves the
1677
- // scope intact on its old script for the next promote to retry.
1678
- await adoptScopeOntoServing(c, s.tenantId, s.id);
1739
+ try {
1740
+ if (!s.servingRef) {
1741
+ // Adopt: export from the scope's current (un-rebound) dispatch → serving script,
1742
+ // then bind to the serving version. Data-first, so a failure here leaves the
1743
+ // scope intact on its old script for the next promote to retry.
1744
+ await adoptScopeOntoServing(c, s.tenantId, s.id, { acknowledge });
1745
+ }
1746
+ else if (s.verticalVersionId !== versionId) {
1747
+ // Already on the serving script (born there, or adopted earlier): routing is
1748
+ // pinned to servingRef, so advancing the version pointer only affects Update
1749
+ // offers. Snapshot on a migration-digest crossing (fork-before-promote, §4).
1750
+ await admin.bindScopeVersion(actor, s.tenantId, s.id, versionId, { snapshot: true, acknowledge });
1751
+ }
1679
1752
  }
1680
- else if (s.verticalVersionId !== versionId) {
1681
- // Already on the serving script (born there, or adopted earlier): routing is
1682
- // pinned to servingRef, so advancing the version pointer only affects Update
1683
- // offers. Snapshot on a migration-digest crossing (fork-before-promote, §4).
1684
- await admin.bindScopeVersion(actor, s.tenantId, s.id, versionId, { snapshot: true });
1753
+ catch (e) {
1754
+ if (e instanceof ExportBreakRefused)
1755
+ refused.push(...e.breaks);
1756
+ else if (isBindExportBreakRefusal(e) || (e instanceof ControlPlaneError && e.message.startsWith(BIND_EXPORT_BREAK_REFUSAL))) {
1757
+ refusal ??= e.message; // the host's own refusal (the answer moved under us): counts only
1758
+ }
1759
+ else
1760
+ throw e;
1685
1761
  }
1686
1762
  }
1763
+ return { refused, ...(refusal ? { refusal } : {}) };
1687
1764
  };
1688
1765
  /**
1689
1766
  * Move ONE scope onto a DIFFERENT vertical lineage's serving script (#389) — the
@@ -1721,13 +1798,20 @@ export function createControlPlaneApi(options) {
1721
1798
  await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
1722
1799
  return { servingRef: serving.ref, versionId: serving.versionId, alreadyBound: true };
1723
1800
  }
1801
+ // #1756: within one lineage this is an adopt, and judged as one before anything moves. A
1802
+ // crossing is not judged (`bindExportBreaksOf`): the producer's slug itself changes.
1803
+ await refuseOnBreaks(actor, tenantId, scopeId, serving.versionId, {
1804
+ servingRef: serving.ref,
1805
+ acknowledge: opts.acknowledge,
1806
+ });
1807
+ const move = { acknowledge: opts.acknowledge };
1724
1808
  if (opts.abandonData) {
1725
1809
  // Directory-only crossing: no bytes move, so the frontier gate has nothing to
1726
1810
  // protect — the target provisions its own schema from scratch. The source
1727
1811
  // script's copy is untouched and remains the backout, same as a carried rebind.
1728
1812
  // The scope serves nothing until `/verticals/:slug/instances` re-provisions it.
1729
- await admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref);
1730
- await admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId);
1813
+ await admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref, move).catch(relayHostRefusal);
1814
+ await admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId, move).catch(relayHostRefusal);
1731
1815
  return { servingRef: serving.ref, versionId: serving.versionId, dataAbandoned: true };
1732
1816
  }
1733
1817
  // The frontier gate. Digest equality proves the target's migration set is exactly
@@ -1755,8 +1839,8 @@ export function createControlPlaneApi(options) {
1755
1839
  // `bindScopeVersion` rewrites `scopes.vertical` from the version row, audited. No
1756
1840
  // extra snapshot here (adopt-serving's precedent): the source script's copy is the
1757
1841
  // pre-migration state, and it is never deleted — that copy is the backout.
1758
- await admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref);
1759
- await admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId);
1842
+ await admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref, move).catch(relayHostRefusal);
1843
+ await admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId, move).catch(relayHostRefusal);
1760
1844
  // #1674: re-assert the recorded OFF positions in the store the scope now routes to.
1761
1845
  await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
1762
1846
  return { servingRef: serving.ref, versionId: serving.versionId, tables: restored.tables };
@@ -3231,13 +3315,14 @@ export function createControlPlaneApi(options) {
3231
3315
  app.post('/tenants/:tenantId/scopes/:scopeId/adopt-serving', async (c) => {
3232
3316
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
3233
3317
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
3318
+ const { acknowledge } = adoptServingBody.parse(await c.req.json().catch(() => ({})));
3234
3319
  try {
3235
- const r = await adoptScopeOntoServing(c, tenantId, scopeId);
3320
+ const r = await adoptScopeOntoServing(c, tenantId, scopeId, { acknowledge });
3236
3321
  return c.json({ adopted: scopeId, ...r });
3237
3322
  }
3238
3323
  catch (e) {
3239
3324
  if (e instanceof ControlPlaneError) {
3240
- return c.json({ error: e.message }, e.status);
3325
+ return c.json(exportBreakBody(e), e.status);
3241
3326
  }
3242
3327
  throw e;
3243
3328
  }
@@ -3264,17 +3349,18 @@ export function createControlPlaneApi(options) {
3264
3349
  return c.json({ error: 'adopt-serving is a private-vertical operation' }, 409);
3265
3350
  }
3266
3351
  const owned = (await admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug })).filter((s) => !s.forkedFrom && s.kind !== 'preview' && s.status === 'active');
3352
+ const { acknowledge } = adoptServingBody.parse(await c.req.json().catch(() => ({})));
3267
3353
  const adopted = [];
3268
3354
  const alreadyAdopted = [];
3269
3355
  try {
3270
3356
  for (const s of owned) {
3271
- const r = await adoptScopeOntoServing(c, s.tenantId, s.id);
3357
+ const r = await adoptScopeOntoServing(c, s.tenantId, s.id, { acknowledge });
3272
3358
  (r.alreadyAdopted ? alreadyAdopted : adopted).push(s.id);
3273
3359
  }
3274
3360
  }
3275
3361
  catch (e) {
3276
3362
  if (e instanceof ControlPlaneError) {
3277
- return c.json({ error: e.message, adopted, alreadyAdopted }, e.status);
3363
+ return c.json({ ...exportBreakBody(e), adopted, alreadyAdopted }, e.status);
3278
3364
  }
3279
3365
  throw e;
3280
3366
  }
@@ -3288,21 +3374,34 @@ export function createControlPlaneApi(options) {
3288
3374
  app.post('/tenants/:tenantId/scopes/:scopeId/rebind-vertical', async (c) => {
3289
3375
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
3290
3376
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
3291
- const { vertical, ackMigrations, abandonData } = rebindScopeVerticalBody.parse(await c.req.json());
3377
+ const { vertical, ackMigrations, abandonData, acknowledge } = rebindScopeVerticalBody.parse(await c.req.json());
3292
3378
  try {
3293
3379
  const r = await rebindScopeOntoVertical(c, tenantId, scopeId, vertical, {
3294
3380
  ackMigrations,
3295
3381
  abandonData,
3382
+ acknowledge,
3296
3383
  });
3297
3384
  return c.json({ rebound: scopeId, vertical, ...r });
3298
3385
  }
3299
3386
  catch (e) {
3300
3387
  if (e instanceof ControlPlaneError) {
3301
- return c.json({ error: e.message }, e.status);
3388
+ return c.json(exportBreakBody(e), e.status);
3302
3389
  }
3303
3390
  throw e;
3304
3391
  }
3305
3392
  });
3393
+ // #1756: which apps in this tenant binding `versionId` onto the scope would break — the
3394
+ // question the bind below refuses on, as a read, so a client can ask it before it records
3395
+ // anything of its own (the dashboard's Activity line) and show what an acknowledgement would
3396
+ // break. Refuses what the bind would refuse first (unknown scope or version, not admitted).
3397
+ // Callers: staff, and a tenant's own credential pinned to the tenant in the path; a builder's
3398
+ // push token is not in BUILDER_ROUTES. Every row is in that tenant.
3399
+ app.get('/tenants/:tenantId/scopes/:scopeId/binding-impact', async (c) => {
3400
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
3401
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
3402
+ const versionId = z.string().min(1).parse(c.req.query('versionId'));
3403
+ return c.json({ affected: await admin.bindingImpact(c.get('actor'), tenantId, scopeId, versionId) });
3404
+ });
3306
3405
  // Pin a scope to a vertical version (#31; orchestration.md §4). Refuses a
3307
3406
  // non-admitted version below the seam — that refusal is the registry's reason to
3308
3407
  // exist. A scope operation, so it keeps the scope route shape. `snapshot: true`
@@ -3318,9 +3417,16 @@ export function createControlPlaneApi(options) {
3318
3417
  app.post('/tenants/:tenantId/scopes/:scopeId/version', async (c) => {
3319
3418
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
3320
3419
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
3321
- const { versionId, snapshot } = bindScopeVersionBody.parse(await c.req.json());
3420
+ const { versionId, snapshot, acknowledge } = bindScopeVersionBody.parse(await c.req.json());
3322
3421
  const actor = c.get('actor');
3323
3422
  const scope = await admin.getScopeRecord(actor, tenantId, scopeId);
3423
+ // #1756: the apps in this tenant the bind would break, asked BEFORE the carry and the
3424
+ // snapshot below, which move data. The host refuses too, whoever calls, but by then a
3425
+ // refusal would land after the bytes had moved. It refuses what the bind would refuse
3426
+ // first (a version not admitted) the same way. An unknown scope has no impact to read, and
3427
+ // the bind below refuses it as it always has.
3428
+ if (scope)
3429
+ await refuseOnBreaks(actor, tenantId, scopeId, versionId, { acknowledge });
3324
3430
  // A refusal throws a ControlPlaneError, which the app's error boundary relays with its
3325
3431
  // status and records as an ops failure. An unknown scope carries nothing, and the bind
3326
3432
  // below refuses it as it always has.
@@ -3334,6 +3440,9 @@ export function createControlPlaneApi(options) {
3334
3440
  if (scope)
3335
3441
  await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
3336
3442
  };
3443
+ // The host's own refusal, should the impact have changed since it was read above (a
3444
+ // promote landing in between): relayed as the same 409, never as a 500.
3445
+ const bind = (opts) => admin.bindScopeVersion(actor, tenantId, scopeId, versionId, opts).catch(relayHostRefusal);
3337
3446
  if (snapshot) {
3338
3447
  if (!scope) {
3339
3448
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
@@ -3351,19 +3460,23 @@ export function createControlPlaneApi(options) {
3351
3460
  migrationCrossing = Boolean(current && incoming && current.migrationDigest !== incoming.migrationDigest);
3352
3461
  }
3353
3462
  await carry();
3354
- if (migrationCrossing)
3463
+ if (migrationCrossing) {
3464
+ // Asked again after the carry: a promote landing since the first question would
3465
+ // otherwise leave an archive behind for a bind the host then refuses.
3466
+ await refuseOnBreaks(actor, tenantId, scopeId, versionId, { acknowledge });
3355
3467
  await orchestratedSnapshot(c, tenantId, scope, {});
3356
- await admin.bindScopeVersion(actor, tenantId, scopeId, versionId);
3468
+ }
3469
+ await bind({ acknowledge });
3357
3470
  }
3358
3471
  else {
3359
3472
  await carry();
3360
- await admin.bindScopeVersion(actor, tenantId, scopeId, versionId, { snapshot: true });
3473
+ await bind({ acknowledge, snapshot: true });
3361
3474
  }
3362
3475
  await reassert();
3363
3476
  return c.json(await admin.getScopeRecord(actor, tenantId, scopeId));
3364
3477
  }
3365
3478
  await carry();
3366
- await admin.bindScopeVersion(actor, tenantId, scopeId, versionId);
3479
+ await bind({ acknowledge });
3367
3480
  await reassert();
3368
3481
  return c.json(await admin.getScopeRecord(actor, tenantId, scopeId));
3369
3482
  });
@@ -3534,6 +3647,23 @@ export function createControlPlaneApi(options) {
3534
3647
  const pin = confinedTenant(p);
3535
3648
  return pin !== null && (await ownerOf(p.actor, slug)) !== pin;
3536
3649
  };
3650
+ /**
3651
+ * The READ twin of `notOwned`, for the routes an installed app's own pages ask
3652
+ * (versions, channels, and one version's registry, schedules, flow, model, assets). A
3653
+ * LISTED vertical is installable by every tenant, so every tenant running it reads what
3654
+ * it runs; ownership decides who may push or promote it, not who may see it. Without
3655
+ * this, the dashboard's tenant-narrowed seam (#977) reads a listed vertical another
3656
+ * tenant published as absent, and its app page shows no version at all. A PRIVATE
3657
+ * vertical stays owner-only, and so do the reads narrowed on purpose (migration SQL,
3658
+ * the prod history).
3659
+ */
3660
+ const notReadable = async (p, slug) => {
3661
+ const pin = confinedTenant(p);
3662
+ if (pin === null)
3663
+ return false;
3664
+ const v = await verticalOf(p.actor, slug);
3665
+ return !v || (v.ownerTenant !== pin && !v.listed);
3666
+ };
3537
3667
  // The vertical id a request actually addresses. For a BUILDER it is `<tenantSlug>/<name>`
3538
3668
  // (builder-plane.md §5): they send a bare `--slug`, the control plane forms the prefix
3539
3669
  // from their authenticated tenant — so two builders can each own a `helpdesk` with no
@@ -3708,7 +3838,7 @@ export function createControlPlaneApi(options) {
3708
3838
  const slug = await resolveVerticalId(c, c.req.param('slug'));
3709
3839
  // A builder reading a vertical it does not own gets 404 — indistinguishable from
3710
3840
  // absent, the same fail-closed reflex K-3 uses for a cross-tenant scope.
3711
- if (await notOwned(p, slug)) {
3841
+ if (await notReadable(p, slug)) {
3712
3842
  return c.json({ error: 'not found' }, 404);
3713
3843
  }
3714
3844
  const page = pageParams(c);
@@ -3729,6 +3859,28 @@ export function createControlPlaneApi(options) {
3729
3859
  return c.json({ error: 'forbidden' }, 403);
3730
3860
  }
3731
3861
  input.verticalSlug = slug;
3862
+ // `manifestJson` is a string the schema cannot look inside, and every reader parses it
3863
+ // with `storedDeployManifest` and 500s on a row it refuses: the per-version reads below
3864
+ // (registry, schedules, flow, model, assets), the serving-upload rebuild that promote
3865
+ // and backout run (`serveVersionInPlace`), and `tenant-stores.ts`.
3866
+ // Hold it to that same parser here, at the trust boundary. The STORED
3867
+ // form, not the push-time one: this route keeps accepting what the platform already holds
3868
+ // (no `registry`, pre-#286 nulls), and refuses only what no reader could open. The string
3869
+ // is stored as sent — re-serializing would drop keys a newer publisher carries.
3870
+ if (input.manifestJson != null) {
3871
+ let raw;
3872
+ try {
3873
+ raw = JSON.parse(input.manifestJson);
3874
+ }
3875
+ catch {
3876
+ return c.json({ error: 'manifestJson is not valid JSON' }, 400);
3877
+ }
3878
+ const checked = storedDeployManifest.safeParse(raw);
3879
+ if (!checked.success) {
3880
+ const issue = checked.error.issues[0];
3881
+ return c.json({ error: `manifestJson is not a deploy manifest: ${issue.path.join('.') || '(root)'}: ${issue.message}` }, 400);
3882
+ }
3883
+ }
3732
3884
  await admin.publishVersion(c.get('actor'), input);
3733
3885
  const version = await admin.getVersion(c.get('actor'), input.id, slug);
3734
3886
  return c.json(version, 201);
@@ -3742,7 +3894,7 @@ export function createControlPlaneApi(options) {
3742
3894
  app.get('/verticals/:slug/versions/:id/registry', async (c) => {
3743
3895
  const p = c.get('principal');
3744
3896
  const slug = await resolveVerticalId(c, c.req.param('slug'));
3745
- if (await notOwned(p, slug)) {
3897
+ if (await notReadable(p, slug)) {
3746
3898
  return c.json({ error: 'not found' }, 404);
3747
3899
  }
3748
3900
  const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
@@ -3753,20 +3905,18 @@ export function createControlPlaneApi(options) {
3753
3905
  // and `substrat promote` show before a migration change is acknowledged. `?base=` names the
3754
3906
  // version to compare against (the one `prod` serves); without it every migration is listed.
3755
3907
  // Owner-narrowed like `/registry`, and never widened: migration SQL describes a schema.
3756
- // `migrations` is null for a version whose manifest carries none (pushed before the field,
3757
- // or over the push's cap), which a reader must show as "SQL not available", never as
3758
- // "no migrations". Bounded in count and bytes (`migrationsOnTop`).
3908
+ // `migrations` is null for a version that carries none (pushed before the field, or over
3909
+ // the push's cap), which a reader must show as "SQL not available", never as
3910
+ // "no migrations". Bounded in count and bytes (`migrationsOnTop`). The SQL is stored apart
3911
+ // from the manifest (#1764), so this reads it without parsing either manifest.
3759
3912
  app.get('/verticals/:slug/versions/:id/migrations', async (c) => {
3760
3913
  const p = c.get('principal');
3761
3914
  const slug = await resolveVerticalId(c, c.req.param('slug'));
3762
3915
  if (await notOwned(p, slug)) {
3763
3916
  return c.json({ error: 'not found' }, 404);
3764
3917
  }
3765
- // `versionManifest` refuses a version of another vertical, so `base` cannot reach one.
3766
- const migrationsOf = async (id) => {
3767
- const json = await admin.versionManifest(c.get('actor'), slug, id);
3768
- return json ? (storedDeployManifest.parse(JSON.parse(json)).migrations ?? null) : null;
3769
- };
3918
+ // `versionMigrations` refuses a version of another vertical, so `base` cannot reach one.
3919
+ const migrationsOf = (id) => admin.versionMigrations(c.get('actor'), slug, id);
3770
3920
  const base = c.req.query('base');
3771
3921
  const [incoming, baseline] = await Promise.all([
3772
3922
  migrationsOf(c.req.param('id')),
@@ -3784,7 +3934,7 @@ export function createControlPlaneApi(options) {
3784
3934
  app.get('/verticals/:slug/versions/:id/schedules', async (c) => {
3785
3935
  const p = c.get('principal');
3786
3936
  const slug = await resolveVerticalId(c, c.req.param('slug'));
3787
- if (await notOwned(p, slug)) {
3937
+ if (await notReadable(p, slug)) {
3788
3938
  return c.json({ error: 'not found' }, 404);
3789
3939
  }
3790
3940
  const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
@@ -3807,7 +3957,7 @@ export function createControlPlaneApi(options) {
3807
3957
  app.get('/verticals/:slug/versions/:id/flow', async (c) => {
3808
3958
  const p = c.get('principal');
3809
3959
  const slug = await resolveVerticalId(c, c.req.param('slug'));
3810
- if (await notOwned(p, slug)) {
3960
+ if (await notReadable(p, slug)) {
3811
3961
  return c.json({ error: 'not found' }, 404);
3812
3962
  }
3813
3963
  const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
@@ -3835,7 +3985,7 @@ export function createControlPlaneApi(options) {
3835
3985
  app.get('/verticals/:slug/versions/:id/model', async (c) => {
3836
3986
  const p = c.get('principal');
3837
3987
  const slug = await resolveVerticalId(c, c.req.param('slug'));
3838
- if (await notOwned(p, slug)) {
3988
+ if (await notReadable(p, slug)) {
3839
3989
  return c.json({ error: 'not found' }, 404);
3840
3990
  }
3841
3991
  const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
@@ -3854,7 +4004,7 @@ export function createControlPlaneApi(options) {
3854
4004
  app.get('/verticals/:slug/versions/:id/assets', async (c) => {
3855
4005
  const p = c.get('principal');
3856
4006
  const slug = await resolveVerticalId(c, c.req.param('slug'));
3857
- if (await notOwned(p, slug)) {
4007
+ if (await notReadable(p, slug)) {
3858
4008
  return c.json({ error: 'not found' }, 404);
3859
4009
  }
3860
4010
  const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
@@ -3943,7 +4093,7 @@ export function createControlPlaneApi(options) {
3943
4093
  app.get('/verticals/:slug/channels', async (c) => {
3944
4094
  const p = c.get('principal');
3945
4095
  const slug = await resolveVerticalId(c, c.req.param('slug'));
3946
- if (await notOwned(p, slug)) {
4096
+ if (await notReadable(p, slug)) {
3947
4097
  return c.json({ error: 'not found' }, 404);
3948
4098
  }
3949
4099
  const page = pageParams(c);
@@ -4118,6 +4268,39 @@ export function createControlPlaneApi(options) {
4118
4268
  return { minted: [], error: message };
4119
4269
  }
4120
4270
  };
4271
+ /**
4272
+ * Which installed apps promoting `versionId` to `channel` would break: the promote gate's own
4273
+ * answer (`promotionImpact`, judged from the channel's previous version), and — for a PRIVATE,
4274
+ * dispatch-backed vertical's prod — what adopting each owned install still on its own version
4275
+ * would break (#1756). That adopt runs after the serve and carries the promote's acknowledgement,
4276
+ * so the acknowledgement must be asked against both, or it would cover breaks nobody was shown.
4277
+ * Over-approximates for a host that cannot serve in place, where no adopt runs: an extra line
4278
+ * costs a read, a missing one a silent stall.
4279
+ */
4280
+ const promoteImpactOf = async (actor, slug, channel, versionId) => {
4281
+ const gate = await admin.promotionImpact(actor, slug, channel, versionId);
4282
+ if (channel !== 'prod')
4283
+ return gate;
4284
+ const v = await verticalOf(actor, slug);
4285
+ if (!v || v.ownerTenant === null || v.listed)
4286
+ return gate;
4287
+ const incoming = await admin.getVersion(actor, versionId, slug);
4288
+ if (!incoming?.deploymentRef || incoming.admission !== 'admitted')
4289
+ return gate;
4290
+ const lagging = (await admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug, status: ['active'] })).filter((s) => !s.forkedFrom && s.kind !== 'preview' && !s.servingRef && s.verticalVersionId !== versionId);
4291
+ const seen = new Set(gate.map((b) => `${b.scopeId}|${b.type}`));
4292
+ const out = [...gate];
4293
+ for (const s of lagging) {
4294
+ // After the adopt it runs the promoted version, served in place: that pointer move, from
4295
+ // what it runs now, is the move to judge.
4296
+ for (const b of await admin.bindingImpact(actor, s.tenantId, s.id, versionId)) {
4297
+ const key = `${b.scopeId}|${b.type}`;
4298
+ if (!seen.has(key))
4299
+ (seen.add(key), out.push(b));
4300
+ }
4301
+ }
4302
+ return out;
4303
+ };
4121
4304
  // #1705 PR 3: which installed apps promoting `?versionId=` would break (an export they import,
4122
4305
  // dropped or re-versioned), BEFORE promoting, so a dialog can show it and ask for the
4123
4306
  // acknowledgement up front, as it does for the digests. The promote's own 409 carries the same
@@ -4133,7 +4316,7 @@ export function createControlPlaneApi(options) {
4133
4316
  if (!v || v.ownerTenant !== pin)
4134
4317
  return c.json({ error: 'forbidden' }, 403);
4135
4318
  }
4136
- const breaks = await admin.promotionImpact(c.get('actor'), slug, channel, versionId);
4319
+ const breaks = await promoteImpactOf(c.get('actor'), slug, channel, versionId);
4137
4320
  const { visible, otherTenants } = narrowToCaller(breaks, pin);
4138
4321
  return c.json({ affected: visible, ...(otherTenants ? { otherTenants } : {}) });
4139
4322
  });
@@ -4179,8 +4362,13 @@ export function createControlPlaneApi(options) {
4179
4362
  // the way the store backfill below is: a confined caller sees its own tenant's apps and the
4180
4363
  // rest as a count, never another tenant's id. Read only when there is something to say: on
4181
4364
  // the refusal, and before an acknowledged promotion moves the channel it is measured from.
4365
+ // The whole answer is kept beside the narrowed listing, so the refusal's sentence counts the
4366
+ // same breaks the listing lists (in counts, which name no tenant) — the host's own sentence
4367
+ // counts only the gate's half of them.
4368
+ let counted = [];
4182
4369
  const breaksOf = async () => {
4183
- const breaks = await admin.promotionImpact(c.get('actor'), slug, channel, versionId);
4370
+ const breaks = await promoteImpactOf(c.get('actor'), slug, channel, versionId);
4371
+ counted = breaks;
4184
4372
  if (breaks.length === 0)
4185
4373
  return null;
4186
4374
  const { visible, otherTenants } = narrowToCaller(breaks, confinedTenant(p));
@@ -4202,13 +4390,14 @@ export function createControlPlaneApi(options) {
4202
4390
  exportBreaks: null,
4203
4391
  exportBreaksUnavailable: `which apps it breaks could not be read: ${e instanceof Error ? e.message : String(e)}`,
4204
4392
  }));
4205
- return c.json({ error: err.message, ...listing }, 409);
4393
+ return c.json({ error: listing.exportBreaks ? exportBreakRefusal(counted) : err.message, ...listing }, 409);
4206
4394
  }
4207
4395
  // The in-place serve (#286), prod only, AFTER every promote gate has passed —
4208
4396
  // uploading first would deploy to live scopes before the acknowledgement check.
4209
4397
  // A failed serve is NOT a failed promote: the channel moved (audited), old code
4210
4398
  // still serves, and promoting again retries the upload.
4211
4399
  let backfill = { minted: [] };
4400
+ let adopted = { refused: [] };
4212
4401
  if (channel === 'prod') {
4213
4402
  try {
4214
4403
  await serveVersionInPlace(c.get('actor'), slug, versionId);
@@ -4217,7 +4406,9 @@ export function createControlPlaneApi(options) {
4217
4406
  // dispatch-backed vertical (#321), in the correct order (serve → adopt → rebind),
4218
4407
  // so a legacy scope's data survives the promote instead of being stranded on a
4219
4408
  // fresh per-version script. Retry-safe: nothing rebound these scopes yet.
4220
- await adoptAndRebindOwnedScopes(c, slug, versionId);
4409
+ // Only the export-break acknowledgement is the adopt's to carry: the digest ones are the
4410
+ // promote's own, and a bind's strict acknowledgement refuses them (#1756).
4411
+ adopted = await adoptAndRebindOwnedScopes(c, slug, versionId, acknowledge?.exportBreak ? { exportBreak: true } : undefined);
4221
4412
  // #825: and mint whatever THIS version newly declares for the tenants already
4222
4413
  // installed — after the serve, so the declaration being read is the one now live.
4223
4414
  backfill = await backfillFleetStores(c, slug);
@@ -4231,6 +4422,31 @@ export function createControlPlaneApi(options) {
4231
4422
  }, 502);
4232
4423
  }
4233
4424
  }
4425
+ // #1756: served, and every install that could move has moved and been backfilled, but one
4426
+ // still on its own version would break apps in its tenant if moved onto the serving script —
4427
+ // a move the promote gate never judged, since that install was not on the channel's previous
4428
+ // version. It is left where it is. Promoting again acknowledged moves it.
4429
+ if (adopted.refused.length > 0 || adopted.refusal) {
4430
+ const sentence = adopted.refused.length > 0 ? bindExportBreakRefusal(adopted.refused) : adopted.refusal;
4431
+ return c.json({
4432
+ error: `promoted and served, but an app still on its own version was not moved onto the serving script: ` +
4433
+ `${sentence}. Promote again with the export-break acknowledgement to move it`,
4434
+ ...(adopted.refused.length > 0 ? { exportBreaks: { affected: adopted.refused } } : {}),
4435
+ // Narrowed as the success answer's is: a builder never reads another tenant's id here.
4436
+ ...(backfill.minted.length || backfill.error
4437
+ ? (() => {
4438
+ const { visible, otherTenants } = narrowToCaller(backfill.minted, confinedTenant(c.get('principal')));
4439
+ return {
4440
+ storeBackfill: {
4441
+ minted: visible,
4442
+ ...(otherTenants ? { otherTenants } : {}),
4443
+ ...(backfill.error ? { error: backfill.error } : {}),
4444
+ },
4445
+ };
4446
+ })()
4447
+ : {}),
4448
+ }, 409);
4449
+ }
4234
4450
  const promoted = (await admin.listChannels(c.get('actor'), slug)).find((ch) => ch.channel === channel);
4235
4451
  // The store backfill rides the response only when it has something to say (#825), so
4236
4452
  // nothing changes for the overwhelming majority of promotes — and when a store WAS