@substrat-run/control-plane-api 0.119.0 → 0.121.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.
package/dist/api.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Hono } from 'hono';
2
2
  import { z } from '@substrat-run/contracts';
3
3
  import type { Connection, ConnectionActivity, ConnectionActivitySource, ConnectionCredential, ConnectionProbe, PlatformActorId } from '@substrat-run/contracts';
4
- import type { ScopeHost } from '@substrat-run/kernel';
4
+ import type { CrossVerticalOptions, ScopeHost } from '@substrat-run/kernel';
5
5
  import type { PlatformActorAuth, BuilderAuth, Principal, TenantServiceAuth } from './auth.js';
6
6
  import { type VerticalClient } from './vertical-client.js';
7
7
  import type { DeployVerticalFn, FetchVerticalAssetFn, FetchVerticalModulesFn } from './deploy.js';
@@ -53,6 +53,14 @@ export interface ControlPlaneApiOptions {
53
53
  */
54
54
  modelMarginPercent?: number;
55
55
  host: ScopeHost;
56
+ /**
57
+ * How edge health reaches the two ends of a cross-vertical edge (#1705 PR 3): the phase's own
58
+ * reach (`hostedCrossVerticalReach` on the control plane). Absent, the host's own verbs, which
59
+ * is right where one host serves every scope. On a host that serves its apps elsewhere (the
60
+ * shared control plane without DISPATCH), the whole read answers `unavailable` instead of
61
+ * reporting no edges.
62
+ */
63
+ crossVertical?: CrossVerticalOptions | undefined;
56
64
  /**
57
65
  * How to reach each vertical, by slug (K-31). Absent slugs simply cannot be
58
66
  * provisioned — the route 501s rather than pretending, because a control plane that
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,13 +1,15 @@
1
+ import { resolveObservabilityWindow } from './observability-window.js';
1
2
  import { Hono } from 'hono';
2
- 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, REDRAIN_BATCH, } 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, 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
4
  import { attributeFailure } from './failure-attribution.js';
4
- import { migrationProgress, ulid } from '@substrat-run/kernel';
5
+ import { crossVerticalHealth, isExportBreakRefusal, migrationProgress, ulid } from '@substrat-run/kernel';
5
6
  import { TENANT_HEADER, confinedTenant } from './auth.js';
6
7
  import { mintTenantToken } from './tenant-token.js';
7
8
  import { connectionGrantsForScope } from './vertical-client.js';
8
9
  import { oidcCallbackUrl, retireClientsOfReapedScope, wirePreviewAuth } from './preview-auth.js';
9
10
  import { versionReachedAt } from './scope-deployment.js';
10
11
  import { reconcileConnectionGrants } from './connection-grants.js';
12
+ import { reconcileThenReassert } from './reconcile.js';
11
13
  import { ConnectionRelayError, relayConnectionUpsert } from './connection-relay.js';
12
14
  import { ControlPlaneError } from './client.js';
13
15
  import { provisionSiblingScope } from './platform-drain.js';
@@ -55,6 +57,19 @@ function outsideTenant(p, tenantId) {
55
57
  const pin = confinedTenant(p);
56
58
  return pin !== null && pin !== tenantId;
57
59
  }
60
+ /**
61
+ * A fleet-wide report, narrowed to what a caller may see: a confined caller gets its own
62
+ * tenant's rows and a COUNT of the other tenants, never their ids (K-3's forced filter, held on
63
+ * reports that have to be computed fleet-wide). Staff (`pin === null`) get every row.
64
+ */
65
+ function narrowToCaller(rows, pin) {
66
+ if (pin === null)
67
+ return { visible: [...rows], otherTenants: 0 };
68
+ return {
69
+ visible: rows.filter((r) => r.tenantId === pin),
70
+ otherTenants: new Set(rows.filter((r) => r.tenantId !== pin).map((r) => r.tenantId)).size,
71
+ };
72
+ }
58
73
  // -- request schemas ---------------------------------------------------------
59
74
  // Parse, don't trust: every input crosses Zod at the boundary. The ids stay
60
75
  // CALLER-SUPPLIED rather than minted here, exactly as the contract has them —
@@ -370,6 +385,19 @@ const tenantRoleAssignmentBody = z
370
385
  * spells out; `.strict()` so a body that tried to name one is refused, not stripped.
371
386
  */
372
387
  const systemSwitchBody = systemSwitch.pick({ moduleId: true, reason: true }).strict();
388
+ /**
389
+ * The fleet read's filter (#1674). `position` defaults to `off` — "what is switched off
390
+ * across the fleet" is the question the route exists for — and `all` asks for both. Paged
391
+ * by `operationId`, ascending by default (oldest switch first, as the admin log reads).
392
+ */
393
+ const systemSwitchesQuery = z.object({
394
+ position: z.enum(['on', 'off', 'all']).default('off'),
395
+ tenantId: tenantIdSchema.optional(),
396
+ scopeId: scopeIdSchema.optional(),
397
+ moduleId: systemSwitch.shape.moduleId.optional(),
398
+ vertical: z.string().min(1).optional(),
399
+ ...listPageQuery.shape,
400
+ });
373
401
  /** The peer kill switch's body (#1706) — the tenant and scope come from the path. */
374
402
  const peerSwitchBody = peerSwitch.pick({ vertical: true, reason: true }).strict();
375
403
  /** A new org under an addressed tenant (#1343) — `tenantId` comes from the path. */
@@ -452,6 +480,7 @@ const TENANT_ROUTES = [
452
480
  { method: 'DELETE', re: /\/verticals\/[^/]+$/, pin: 'owner' },
453
481
  { method: 'GET', re: /\/verticals\/[^/]+\/versions$/, pin: 'owner' },
454
482
  { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/registry$/, pin: 'owner' },
483
+ { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/migrations$/, pin: 'owner' },
455
484
  { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/model$/, pin: 'owner' },
456
485
  { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/flow$/, pin: 'owner' },
457
486
  { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/assets$/, pin: 'owner' },
@@ -459,6 +488,7 @@ const TENANT_ROUTES = [
459
488
  { method: 'GET', re: /\/verticals\/[^/]+\/channels$/, pin: 'owner' },
460
489
  { method: 'GET', re: /\/verticals\/[^/]+\/channels\/[^/]+\/history$/, pin: 'owner' },
461
490
  { method: 'POST', re: /\/verticals\/[^/]+\/channels\/[^/]+\/promote$/, pin: 'owner' },
491
+ { method: 'GET', re: /\/verticals\/[^/]+\/channels\/[^/]+\/promote-impact$/, pin: 'owner' },
462
492
  { method: 'GET', re: /\/verticals\/[^/]+\/previews$/, pin: 'owner' },
463
493
  { method: 'POST', re: /\/verticals\/[^/]+\/previews$/, pin: 'owner' },
464
494
  { method: 'DELETE', re: /\/verticals\/[^/]+\/previews\/[^/]+$/, pin: 'owner' },
@@ -611,6 +641,9 @@ export function createControlPlaneApi(options) {
611
641
  // The declared permission registry of one version (D-39, #336) — owner-narrowed in the
612
642
  // handler like the versions list; the builder-facing Permissions tab reads it.
613
643
  { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/registry$/ },
644
+ // The SQL migrations one version adds on top of another (#1677), owner-narrowed the same
645
+ // way; the promote dialog and `substrat promote` read it.
646
+ { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/migrations$/ },
614
647
  // The emitted entity model of one version (#1214) — owner-narrowed the same way;
615
648
  // the dashboard's Model tab reads it.
616
649
  { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/model$/ },
@@ -1573,7 +1606,8 @@ export function createControlPlaneApi(options) {
1573
1606
  * current dispatch — resolved BEFORE any version rebind), restore into the serving
1574
1607
  * script (which re-projects the vertical's roles), and only then `setScopeServingRef` +
1575
1608
  * advance the version pointer. A crash before the flip leaves the scope serving its old
1576
- * script intact, and the adopt retries idempotently. Already-adopted scopes short-circuit.
1609
+ * script intact, and the adopt retries idempotently. Previews are refused; already-adopted
1610
+ * installs short-circuit.
1577
1611
  * Throws `ControlPlaneError` so callers surface an actionable status, never a bare 500.
1578
1612
  */
1579
1613
  const adoptScopeOntoServing = async (c, tenantId, scopeId) => {
@@ -1582,8 +1616,16 @@ export function createControlPlaneApi(options) {
1582
1616
  if (!scope) {
1583
1617
  throw new ControlPlaneError(404, `unknown scope for tenant: (${tenantId}, ${scopeId})`);
1584
1618
  }
1585
- if (scope.servingRef)
1619
+ if (scope.kind === 'preview') {
1620
+ throw new ControlPlaneError(409, 'preview scopes cannot adopt the production serving script');
1621
+ }
1622
+ if (scope.servingRef) {
1623
+ // #1674 (Copilot review): an adopt that flipped routing and then failed to re-assert is
1624
+ // retried HERE, so the retry re-asserts too — else a store that lost its OFF marker in
1625
+ // the copy would stay on for good. Cheap and idempotent when nothing is owed.
1626
+ await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
1586
1627
  return { servingRef: scope.servingRef, alreadyAdopted: true };
1628
+ }
1587
1629
  if (!scope.vertical) {
1588
1630
  throw new ControlPlaneError(409, 'scope has no vertical — nothing to adopt onto');
1589
1631
  }
@@ -1601,6 +1643,9 @@ export function createControlPlaneApi(options) {
1601
1643
  // Data landed — only now flip routing and move the version pointer.
1602
1644
  await admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref);
1603
1645
  await admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId);
1646
+ // #1674: the scope now routes to a different store, so put the directory's recorded
1647
+ // OFF positions back there — cheap and idempotent when the dump carried them.
1648
+ await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
1604
1649
  return { servingRef: serving.ref, tables: restored.tables };
1605
1650
  };
1606
1651
  /**
@@ -1610,8 +1655,8 @@ export function createControlPlaneApi(options) {
1610
1655
  * pointer to the promoted version so Update stops offering a crossing already made.
1611
1656
  *
1612
1657
  * Gated exactly where the host cascade would have run: PRIVATE (owned, unlisted) only,
1613
- * active non-fork scopes only. Runs only when a serving script exists — i.e. the serve
1614
- * actually happened (dispatch-backed + deploy configured); for an embedded vertical the
1658
+ * active non-fork, non-preview scopes only. Runs only when a serving script exists —
1659
+ * i.e. the serve actually happened (dispatch-backed + deploy configured); for an embedded vertical the
1615
1660
  * host cascade already rebound, and `verticalServing` is null, so this is a no-op.
1616
1661
  * Idempotent and retry-safe: the host cascade never rebound these scopes, so their data
1617
1662
  * is still findable on a retry after a failed serve.
@@ -1624,7 +1669,7 @@ export function createControlPlaneApi(options) {
1624
1669
  const v = await verticalOf(actor, slug);
1625
1670
  if (!v || v.ownerTenant === null || v.listed)
1626
1671
  return; // private only, like the host cascade
1627
- const owned = (await admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug, status: ['active'] })).filter((s) => !s.forkedFrom);
1672
+ const owned = (await admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug, status: ['active'] })).filter((s) => !s.forkedFrom && s.kind !== 'preview');
1628
1673
  for (const s of owned) {
1629
1674
  if (!s.servingRef) {
1630
1675
  // Adopt: export from the scope's current (un-rebound) dispatch → serving script,
@@ -1672,6 +1717,8 @@ export function createControlPlaneApi(options) {
1672
1717
  throw new ControlPlaneError(409, `vertical '${target}' has no serving script yet — promote a version to prod first`);
1673
1718
  }
1674
1719
  if (scope.vertical === target && scope.servingRef === serving.ref) {
1720
+ // #1674: a rebind retried after its re-assert failed lands here; re-assert, as adopt does.
1721
+ await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
1675
1722
  return { servingRef: serving.ref, versionId: serving.versionId, alreadyBound: true };
1676
1723
  }
1677
1724
  if (opts.abandonData) {
@@ -1710,6 +1757,8 @@ export function createControlPlaneApi(options) {
1710
1757
  // pre-migration state, and it is never deleted — that copy is the backout.
1711
1758
  await admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref);
1712
1759
  await admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId);
1760
+ // #1674: re-assert the recorded OFF positions in the store the scope now routes to.
1761
+ await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
1713
1762
  return { servingRef: serving.ref, versionId: serving.versionId, tables: restored.tables };
1714
1763
  };
1715
1764
  /**
@@ -2029,7 +2078,8 @@ export function createControlPlaneApi(options) {
2029
2078
  : null;
2030
2079
  const ranAs = versionReachedAt(reached.via, scope, serving);
2031
2080
  try {
2032
- const result = await vertical.reconcileInstance({
2081
+ // #1674: the recorded OFF positions go back after the reconcile, before the receipt.
2082
+ const result = await reconcileThenReassert(admin, actor, { tenantId, scopeId }, () => vertical.reconcileInstance({
2033
2083
  tenantId,
2034
2084
  scopeId,
2035
2085
  entitlements,
@@ -2040,7 +2090,7 @@ export function createControlPlaneApi(options) {
2040
2090
  // by the vertical, so the reconcile must carry it into the same ready-gate — a bound
2041
2091
  // but unmigrated database fails as loudly as an absent one.
2042
2092
  ...(tenantStores.length ? { tenantStores } : {}),
2043
- });
2093
+ }));
2044
2094
  // #1172: the reconcile succeeded, so record WHICH version it ran against. Without
2045
2095
  // this the sweep would come back and do it again on the next pass — the console
2046
2096
  // button and the automatic phase have to write the same receipt, or pressing the
@@ -3085,6 +3135,9 @@ export function createControlPlaneApi(options) {
3085
3135
  const vertical = await verticalForScope(c, scope);
3086
3136
  if (vertical)
3087
3137
  await retryTransient(() => vertical.restoreScope(tenantId, scopeId, tables));
3138
+ // #1674: a backup from before a module was switched off brings it back on; the
3139
+ // directory's record puts it back off now, not at the next reconcile.
3140
+ await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
3088
3141
  return c.json({ restored: scopeId, tables: tables.length });
3089
3142
  }
3090
3143
  catch (e) {
@@ -3153,10 +3206,15 @@ export function createControlPlaneApi(options) {
3153
3206
  force,
3154
3207
  localApply: !vertical,
3155
3208
  });
3156
- if (vertical) {
3157
- return c.json(await vertical.rewindScope(scopeId, bookmark, { force }));
3158
- }
3159
- return c.json(result);
3209
+ const answer = vertical ? await vertical.rewindScope(scopeId, bookmark, { force }) : result;
3210
+ // #1674: a rewind takes the scope's grants back to the bookmark, and with them any
3211
+ // schedule kill switch pulled since — a tenant owner could otherwise undo a staff
3212
+ // switch this way. Clearing the provisioned receipt, only once the rewind has gone
3213
+ // through, makes the next sweep reconcile the scope, and that reconcile re-asserts
3214
+ // what the directory records as off. Left to the sweep rather than done here: the
3215
+ // scope's store restarts to finish the restore, and a re-assert now would race it.
3216
+ await admin.markScopeProvisioned(actor, tenantId, scopeId, null);
3217
+ return c.json(answer);
3160
3218
  }
3161
3219
  catch (e) {
3162
3220
  if (e instanceof ControlPlaneError) {
@@ -3205,7 +3263,7 @@ export function createControlPlaneApi(options) {
3205
3263
  if (v.ownerTenant === null) {
3206
3264
  return c.json({ error: 'adopt-serving is a private-vertical operation' }, 409);
3207
3265
  }
3208
- const owned = (await admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug })).filter((s) => !s.forkedFrom && s.status === 'active');
3266
+ const owned = (await admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug })).filter((s) => !s.forkedFrom && s.kind !== 'preview' && s.status === 'active');
3209
3267
  const adopted = [];
3210
3268
  const alreadyAdopted = [];
3211
3269
  try {
@@ -3270,6 +3328,12 @@ export function createControlPlaneApi(options) {
3270
3328
  if (scope)
3271
3329
  await carryOntoVersion(c, scope, versionId);
3272
3330
  };
3331
+ // #1674: after the bind, the scope may route to a different store (the carry's
3332
+ // destination); put the directory's recorded OFF positions back there.
3333
+ const reassert = async () => {
3334
+ if (scope)
3335
+ await admin.reassertSystemSwitches(actor, { tenantId, scopeId });
3336
+ };
3273
3337
  if (snapshot) {
3274
3338
  if (!scope) {
3275
3339
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
@@ -3295,10 +3359,12 @@ export function createControlPlaneApi(options) {
3295
3359
  await carry();
3296
3360
  await admin.bindScopeVersion(actor, tenantId, scopeId, versionId, { snapshot: true });
3297
3361
  }
3362
+ await reassert();
3298
3363
  return c.json(await admin.getScopeRecord(actor, tenantId, scopeId));
3299
3364
  }
3300
3365
  await carry();
3301
3366
  await admin.bindScopeVersion(actor, tenantId, scopeId, versionId);
3367
+ await reassert();
3302
3368
  return c.json(await admin.getScopeRecord(actor, tenantId, scopeId));
3303
3369
  });
3304
3370
  // -- instances (K-31) -------------------------------------------------------
@@ -3400,6 +3466,14 @@ export function createControlPlaneApi(options) {
3400
3466
  // one guessed from the serving pointer would claim a hook ran against code this
3401
3467
  // scope was not running.
3402
3468
  const provisioned = await admin.getScopeRecord(c.get('actor'), input.tenantId, input.scopeId);
3469
+ // #1674: this route is idempotent (K-31), so an already-bound scope that was wiped can
3470
+ // come back through it, re-seated live by the deployment — and the receipt below means
3471
+ // no sweep follows. Re-assert the recorded OFF positions before it. Only once the
3472
+ // directory row exists: a brand-new install's is written after this call (see above),
3473
+ // has no record to re-assert, and `reassertSystemSwitches` refuses an unknown scope.
3474
+ if (provisioned) {
3475
+ await admin.reassertSystemSwitches(c.get('actor'), { tenantId: input.tenantId, scopeId: input.scopeId });
3476
+ }
3403
3477
  if (provisioned?.verticalVersionId) {
3404
3478
  await admin.markScopeProvisioned(c.get('actor'), input.tenantId, input.scopeId, provisioned.verticalVersionId);
3405
3479
  }
@@ -3675,6 +3749,31 @@ export function createControlPlaneApi(options) {
3675
3749
  const registry = json ? (storedDeployManifest.parse(JSON.parse(json)).registry ?? null) : null;
3676
3750
  return c.json({ registry });
3677
3751
  });
3752
+ // The SQL migrations one version adds on top of another (#1677): what the promote dialog
3753
+ // and `substrat promote` show before a migration change is acknowledged. `?base=` names the
3754
+ // version to compare against (the one `prod` serves); without it every migration is listed.
3755
+ // 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`).
3759
+ app.get('/verticals/:slug/versions/:id/migrations', async (c) => {
3760
+ const p = c.get('principal');
3761
+ const slug = await resolveVerticalId(c, c.req.param('slug'));
3762
+ if (await notOwned(p, slug)) {
3763
+ return c.json({ error: 'not found' }, 404);
3764
+ }
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
+ };
3770
+ const base = c.req.query('base');
3771
+ const [incoming, baseline] = await Promise.all([
3772
+ migrationsOf(c.req.param('id')),
3773
+ base === undefined ? undefined : migrationsOf(base),
3774
+ ]);
3775
+ return c.json({ migrations: incoming === null ? null : migrationsOnTop(incoming, baseline) });
3776
+ });
3678
3777
  // The emitted entity model (#1214) one version ships: entities, field schemas, parent
3679
3778
  // edges, declared lifecycles — read out of the same retained manifest as the registry
3680
3779
  // above, and owner-narrowed the same way. `model` is null for a version pushed by a
@@ -4019,6 +4118,25 @@ export function createControlPlaneApi(options) {
4019
4118
  return { minted: [], error: message };
4020
4119
  }
4021
4120
  };
4121
+ // #1705 PR 3: which installed apps promoting `?versionId=` would break (an export they import,
4122
+ // dropped or re-versioned), BEFORE promoting, so a dialog can show it and ask for the
4123
+ // acknowledgement up front, as it does for the digests. The promote's own 409 carries the same
4124
+ // listing. Narrowed to the caller exactly as that 409 is. Owner-checked like the promote.
4125
+ app.get('/verticals/:slug/channels/:channel/promote-impact', async (c) => {
4126
+ const p = c.get('principal');
4127
+ const slug = await resolveVerticalId(c, c.req.param('slug'));
4128
+ const channel = channelName.parse(c.req.param('channel'));
4129
+ const versionId = z.string().min(1).parse(c.req.query('versionId'));
4130
+ const pin = confinedTenant(p);
4131
+ if (pin) {
4132
+ const v = await verticalOf(p.actor, slug);
4133
+ if (!v || v.ownerTenant !== pin)
4134
+ return c.json({ error: 'forbidden' }, 403);
4135
+ }
4136
+ const breaks = await admin.promotionImpact(c.get('actor'), slug, channel, versionId);
4137
+ const { visible, otherTenants } = narrowToCaller(breaks, pin);
4138
+ return c.json({ affected: visible, ...(otherTenants ? { otherTenants } : {}) });
4139
+ });
4022
4140
  app.post('/verticals/:slug/channels/:channel/promote', async (c) => {
4023
4141
  const p = c.get('principal');
4024
4142
  const slug = await resolveVerticalId(c, c.req.param('slug'));
@@ -4055,10 +4173,37 @@ export function createControlPlaneApi(options) {
4055
4173
  }
4056
4174
  }
4057
4175
  const { versionId, acknowledge } = promoteVersionBody.parse(await c.req.json());
4176
+ // #1705 PR 3: the installed consumers a promotion breaks (an export they import, dropped or
4177
+ // re-versioned). The host refuses it without `exportBreak` whoever calls, in counts only,
4178
+ // because it does not know who is asking. This door knows, so it adds the listing, narrowed
4179
+ // the way the store backfill below is: a confined caller sees its own tenant's apps and the
4180
+ // rest as a count, never another tenant's id. Read only when there is something to say: on
4181
+ // the refusal, and before an acknowledged promotion moves the channel it is measured from.
4182
+ const breaksOf = async () => {
4183
+ const breaks = await admin.promotionImpact(c.get('actor'), slug, channel, versionId);
4184
+ if (breaks.length === 0)
4185
+ return null;
4186
+ const { visible, otherTenants } = narrowToCaller(breaks, confinedTenant(p));
4187
+ return { affected: visible, ...(otherTenants ? { otherTenants } : {}) };
4188
+ };
4189
+ const exportBreaks = acknowledge?.exportBreak ? await breaksOf() : null;
4058
4190
  // The blast-radius moment: refuses a changed digest without the acknowledgement,
4059
4191
  // and refuses a non-admitted version. Both are enforced below the seam and
4060
4192
  // surface as a 4xx through mapError, not a 500.
4061
- await admin.promoteVersion(c.get('actor'), slug, channel, versionId, acknowledge);
4193
+ try {
4194
+ await admin.promoteVersion(c.get('actor'), slug, channel, versionId, acknowledge);
4195
+ }
4196
+ catch (err) {
4197
+ if (!isExportBreakRefusal(err))
4198
+ throw err;
4199
+ // The refusal stands whether or not the listing can be read. A listing that fails turns
4200
+ // into a note, never into a 500 that would hide that the promote was refused.
4201
+ const listing = await breaksOf().then((exportBreaks) => ({ exportBreaks }), (e) => ({
4202
+ exportBreaks: null,
4203
+ exportBreaksUnavailable: `which apps it breaks could not be read: ${e instanceof Error ? e.message : String(e)}`,
4204
+ }));
4205
+ return c.json({ error: err.message, ...listing }, 409);
4206
+ }
4062
4207
  // The in-place serve (#286), prod only, AFTER every promote gate has passed —
4063
4208
  // uploading first would deploy to live scopes before the acknowledgement check.
4064
4209
  // A failed serve is NOT a failed promote: the channel moved (audited), old code
@@ -4098,12 +4243,11 @@ export function createControlPlaneApi(options) {
4098
4243
  // report must not be the one place that hands them the tenant ids of everyone who
4099
4244
  // installed their vertical. The rest is a count, which tells them the fleet was covered
4100
4245
  // without naming who is in it.
4101
- const p2 = c.get('principal');
4102
- const reportPin = confinedTenant(p2);
4103
- const visible = reportPin ? backfill.minted.filter((m) => m.tenantId === reportPin) : backfill.minted;
4104
- const otherTenants = new Set(backfill.minted.filter((m) => !visible.includes(m)).map((m) => m.tenantId)).size;
4246
+ const { visible, otherTenants } = narrowToCaller(backfill.minted, confinedTenant(c.get('principal')));
4105
4247
  return c.json({
4106
4248
  ...promoted,
4249
+ // What the acknowledged break reached, so a promoter who passed `exportBreak` reads it.
4250
+ ...(exportBreaks ? { exportBreaks } : {}),
4107
4251
  ...(backfill.minted.length || backfill.error
4108
4252
  ? {
4109
4253
  storeBackfill: {
@@ -4561,14 +4705,28 @@ export function createControlPlaneApi(options) {
4561
4705
  // than somebody else's, and needs no ownership check of its own to be safe.
4562
4706
  scopeId: z.string().min(1).max(64).optional(),
4563
4707
  vertical: z.string().min(1).max(200).optional(),
4708
+ // A coarser grouping of the same rows (#1767) — never a wider read.
4709
+ grain: z.enum(['surface', 'scope']).optional(),
4564
4710
  hours: z.coerce.number().int().min(1).max(72).default(24),
4711
+ since: z.string().datetime({ offset: true }).optional(),
4712
+ until: z.string().datetime({ offset: true }).optional(),
4565
4713
  })
4566
4714
  .parse({
4567
4715
  tenantId,
4568
4716
  scopeId: c.req.query('scopeId') || undefined,
4569
4717
  vertical: c.req.query('vertical') || undefined,
4570
- hours: c.req.query('hours'),
4718
+ grain: c.req.query('grain') || undefined,
4719
+ hours: c.req.query('hours'), since: c.req.query('since'), until: c.req.query('until'),
4571
4720
  });
4721
+ try {
4722
+ resolveObservabilityWindow(input);
4723
+ }
4724
+ catch (e) {
4725
+ throw new ControlPlaneError(400, e.message);
4726
+ }
4727
+ if (input.since !== undefined && !options.observability.absoluteTenantWindows) {
4728
+ throw new ControlPlaneError(501, 'absolute tenant telemetry windows are not supported by this backend');
4729
+ }
4572
4730
  return c.json(await options.observability.tenantMetrics(input));
4573
4731
  });
4574
4732
  // The bucketed twin (#1447): the series a team-level chart plots, one line per app.
@@ -4592,8 +4750,19 @@ export function createControlPlaneApi(options) {
4592
4750
  // zero rows rather than somebody else's, so no ownership check is needed here.
4593
4751
  scopeIds: z.array(z.string().min(1).max(64)).min(1).max(TENANT_SERIES_SCOPE_CAP),
4594
4752
  hours: z.coerce.number().int().min(1).max(72).default(24),
4753
+ since: z.string().datetime({ offset: true }).optional(),
4754
+ until: z.string().datetime({ offset: true }).optional(),
4595
4755
  })
4596
- .parse({ tenantId, scopeIds, hours: c.req.query('hours') });
4756
+ .parse({ tenantId, scopeIds, hours: c.req.query('hours'), since: c.req.query('since'), until: c.req.query('until') });
4757
+ try {
4758
+ resolveObservabilityWindow(input);
4759
+ }
4760
+ catch (e) {
4761
+ throw new ControlPlaneError(400, e.message);
4762
+ }
4763
+ if (input.since !== undefined && !options.observability.absoluteTenantWindows) {
4764
+ throw new ControlPlaneError(501, 'absolute tenant telemetry windows are not supported by this backend');
4765
+ }
4597
4766
  return c.json(await options.observability.tenantMetricsSeries(input));
4598
4767
  });
4599
4768
  app.get('/observability/tenant-logs', async (c) => {
@@ -5213,6 +5382,8 @@ export function createControlPlaneApi(options) {
5213
5382
  if (existing.servingRef)
5214
5383
  await admin.setScopeServingRef(actor, tenantId, existing.id, null);
5215
5384
  await admin.bindScopeVersion(actor, tenantId, existing.id, opts.versionId);
5385
+ // #1674: re-assert any recorded OFF in the script the preview now routes to.
5386
+ await admin.reassertSystemSwitches(actor, { tenantId, scopeId: existing.id });
5216
5387
  // Renew (or clear) the preview's GC deadline so a reused preview does not silently die.
5217
5388
  await admin.setScopeExpiresAt(actor, tenantId, existing.id, expiresAt);
5218
5389
  const hostname = await bindPreviewHostname(actor, baseHostname, tenantId, existing.id, opts.tag, surface);
@@ -5572,6 +5743,36 @@ export function createControlPlaneApi(options) {
5572
5743
  }
5573
5744
  return c.json(await admin.systemGrantsStatus(actor, { tenantId, scopeId }));
5574
5745
  });
5746
+ // The fleet read (#1674): every scope with a module switched off, from the directory's
5747
+ // record of the switch, so it needs no walk of every scope's store. The record is not the
5748
+ // gate; the per-scope read above is where a drift between the two shows (`recorded`).
5749
+ //
5750
+ // **Staff and the platform service token ONLY.** Outside `/tenants/:id`, and in neither
5751
+ // BUILDER_ROUTES nor TENANT_ROUTES, so a builder and a tenant credential are both refused
5752
+ // by default-deny before this runs. The `confinedTenant` check is the second lock, not
5753
+ // the first: a fleet-wide read is exactly what a confined credential must never make, and
5754
+ // it must not start answering if this path is ever allowlisted for a narrowed read.
5755
+ app.get('/system-switches', async (c) => {
5756
+ if (confinedTenant(c.get('principal')) !== null) {
5757
+ return c.json({ error: 'forbidden: the schedule switch is staff-only' }, 403);
5758
+ }
5759
+ const q = systemSwitchesQuery.parse({
5760
+ position: c.req.query('position'),
5761
+ tenantId: c.req.query('tenantId'),
5762
+ scopeId: c.req.query('scopeId'),
5763
+ moduleId: c.req.query('moduleId'),
5764
+ vertical: c.req.query('vertical'),
5765
+ limit: c.req.query('limit'),
5766
+ cursor: c.req.query('cursor'),
5767
+ order: c.req.query('order'),
5768
+ });
5769
+ const { position, ...rest } = q;
5770
+ const entries = await admin.listSystemSwitches(c.get('actor'), {
5771
+ ...rest,
5772
+ ...(position === 'all' ? {} : { position }),
5773
+ });
5774
+ return c.json(pageOf(entries, q.limit, (e) => e.operationId));
5775
+ });
5575
5776
  // -- the peer kill switch (#1706) ------------------------------------------
5576
5777
  //
5577
5778
  // One calling vertical's access to ONE scope, off and back on — `revokeFromPeer` and
@@ -5694,6 +5895,51 @@ export function createControlPlaneApi(options) {
5694
5895
  const entries = await admin.listSweepRuns(c.get('actor'), filter);
5695
5896
  return c.json(pageOf(entries, filter.limit, (e) => e.id));
5696
5897
  });
5898
+ // -- the replay lever (#1705 PR 3): move a consumer's watermark on one edge ----
5899
+ // Staff (the console) and a tenant credential (the dashboard, for someone who may manage the
5900
+ // tenant's apps) may pull it: the edge is between two of the tenant's own apps, and what a
5901
+ // replay runs lands in the tenant's own scope. The peer switch's posture, for that reason.
5902
+ // Builders may not: this route is absent from BUILDER_ROUTES, which is default-deny.
5903
+ //
5904
+ // The acknowledgement is refused HERE with the sentence it stands for, rather than left to a
5905
+ // Zod message about a literal. The person pulling the lever has to be told, in words, that a
5906
+ // replay runs handlers again and repeats what they send or call outside the app.
5907
+ app.post('/tenants/:tenantId/scopes/:scopeId/import-cursor', async (c) => {
5908
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
5909
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
5910
+ const pin = confinedTenant(c.get('principal'));
5911
+ if (pin !== null && pin !== tenantId)
5912
+ return c.json({ error: 'forbidden' }, 403);
5913
+ const raw = await c.req.json().catch(() => ({}));
5914
+ const refused = importCursorAcknowledgementMissing(raw);
5915
+ if (refused)
5916
+ return c.json({ error: refused }, 400);
5917
+ const move = importCursorMove.parse(raw);
5918
+ const actor = c.get('actor');
5919
+ // K-3 first, so a scope of another tenant reads as absent before anything is reached.
5920
+ if (!(await admin.getScopeRecord(actor, tenantId, scopeId))) {
5921
+ return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
5922
+ }
5923
+ return c.json(await admin.moveImportCursor(actor, tenantId, scopeId, move));
5924
+ });
5925
+ // -- edge health (#1705 PR 3): where each cross-vertical edge of one tenant stands -
5926
+ // Read live, through the phase's own reach, so it cannot disagree with the next pass about
5927
+ // which edges exist. Staff and the tenant's own credential, tenant-pinned (the dashboard shows
5928
+ // a tenant its own apps' edges). Builders may not: absent from BUILDER_ROUTES. `?scopeId=`
5929
+ // narrows it to one scope's edges, so a per-app view asks nothing about the rest.
5930
+ app.get('/tenants/:tenantId/cross-vertical/edges', async (c) => {
5931
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
5932
+ const pin = confinedTenant(c.get('principal'));
5933
+ if (pin !== null && pin !== tenantId)
5934
+ return c.json({ error: 'forbidden' }, 403);
5935
+ const focus = c.req.query('scopeId');
5936
+ return c.json(await crossVerticalHealth(host, {
5937
+ actor: c.get('actor'),
5938
+ tenantId,
5939
+ ...(focus ? { focus: scopeIdSchema.parse(focus) } : {}),
5940
+ ...(options.crossVertical ? { crossVertical: options.crossVertical } : {}),
5941
+ }));
5942
+ });
5697
5943
  // -- issues (#1233): failures grouped by fingerprint, with a lifecycle --------
5698
5944
  // Staff-only (absent from BUILDER_ROUTES): an issue is a fleet-scoped aggregate
5699
5945
  // with no tenant column, so the forced-filter posture cannot narrow it — a