@substrat-run/control-plane-api 0.120.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":"AACA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,OAAO,EAyEL,CAAC,EAQF,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EACV,UAAU,EACV,kBAAkB,EAClB,wBAAwB,EACxB,oBAAoB,EAEpB,eAAe,EAGf,eAAe,EAQhB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,oBAAoB,EAAmB,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAI7F,OAAO,KAAK,EAAE,iBAAiB,EAAE,WAAW,EAAE,SAAS,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAE9F,OAAO,EAA4B,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAsBrF,OAAO,KAAK,EAEV,gBAAgB,EAChB,oBAAoB,EACpB,sBAAsB,EACvB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AAyBtD,OAAO,EAA2B,KAAK,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AACvF,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,EAAuB,KAAK,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AACjF,OAAO,KAAK,EAEV,oBAAoB,EAEpB,gBAAgB,EACjB,MAAM,cAAc,CAAC;AAGtB,OAAO,EAGL,KAAK,yBAAyB,EAC/B,MAAM,uBAAuB,CAAC;AAE/B;;;;;;;;;;GAUG;AACH,MAAM,WAAW,mBAAmB;IAClC,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,UAAU,EAAE,UAAU,KAAK,OAAO,CAAC,eAAe,CAAC,CAAC;IAC9E,QAAQ,CAAC,EAAE,CACT,IAAI,EAAE,SAAS,EACf,UAAU,EAAE,UAAU,EACtB,IAAI,EAAE;QAAE,IAAI,EAAE,OAAO,CAAC;QAAC,MAAM,EAAE,wBAAwB,CAAA;KAAE,KACtD,OAAO,CAAC,kBAAkB,CAAC,CAAC;IACjC;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,UAAU,EAAE,UAAU,KAAK,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACxF;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,OAAO,CAAC,eAAe,CAAC,CAAC;CAC/E;AAED,MAAM,WAAW,sBAAsB;IACrC;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,IAAI,EAAE,SAAS,CAAC;IAChB;;;;;;OAMG;IACH,aAAa,CAAC,EAAE,oBAAoB,GAAG,SAAS,CAAC;IACjD;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IAC3C;;;;;;;;OAQG;IACH,eAAe,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAC;IAC9D;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,eAAe,KAAK,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IAChG;;;;;;;;;OASG;IACH,sBAAsB,CAAC,EAAE,CACvB,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,eAAe,KACnB,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IACzC;;;;;;;OAOG;IACH,kBAAkB,CAAC,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IACpF;;;;;OAKG;IACH,cAAc,CAAC,EAAE,gBAAgB,CAAC;IAClC;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,sBAAsB,CAAC;IAC9C;;;;;;;;;;OAUG;IACH,kBAAkB,CAAC,EAAE,oBAAoB,CAAC;IAC1C;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,qBAAqB,CAAC;IAC5C;;;;;;;;OAQG;IACH,sBAAsB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3C;;;;OAIG;IACH,YAAY,EAAE,iBAAiB,CAAC;IAChC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,WAAW,CAAC;IAClC;;;;;;OAMG;IACH,yBAAyB,CAAC,EAAE,iBAAiB,CAAC;IAC9C;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;;;;;;OASG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,mBAAmB,CAAC;IACpC;;;;;;;;;;;;OAYG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAC;IAC3D;;;;;;;;;OASG;IACH,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC;;;;;OAKG;IACH,YAAY,CAAC,EAAE,iBAAiB,CAAC;IACjC;;;;;;;;;;;;;OAaG;IACH,YAAY,CAAC,EAAE,gBAAgB,CAAC;IAChC;;;;;;;;;OASG;IACH,gBAAgB,CAAC,EAAE,oBAAoB,CAAC;IACxC;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,yBAAyB,CAAC;IAC9C;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC/B;;;;;;;;;;;OAWG;IACH,WAAW,CAAC,EAAE;QAAE,UAAU,CAAC,EAAE,MAAM,CAAC;QAAC,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAC/D;AAED;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,+BAA+B,CAAC;AACnE,eAAO,MAAM,yBAAyB,kCAAkC,CAAC;AAKzE,KAAK,IAAI,GAAG;IAAE,KAAK,EAAE,eAAe,CAAC;IAAC,SAAS,EAAE,SAAS,CAAA;CAAE,CAAC;AAuQ7D;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,cAAc;;;;;;;;;;;iBASzB,CAAC;AAqCH,yFAAyF;AACzF,eAAO,MAAM,yBAAyB,MAAM,CAAC;AA8G7C,eAAO,MAAM,4BAA4B,KAAK,CAAC;AAuC/C,KAAK,SAAS,GAAG,MAAM,GAAG,OAAO,GAAG,SAAS,GAAG,MAAM,GAAG,OAAO,CAAC;AAuEjE;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,CAEvF;AAuBD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,GAAG,IAAI,CAAC;IAAE,SAAS,EAAE,IAAI,CAAA;CAAE,CAAC,CAswLhG"}
package/dist/api.js CHANGED
@@ -1,14 +1,15 @@
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, 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';
4
4
  import { attributeFailure } from './failure-attribution.js';
5
- import { migrationProgress, ulid } from '@substrat-run/kernel';
5
+ import { crossVerticalHealth, 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';
9
9
  import { oidcCallbackUrl, retireClientsOfReapedScope, wirePreviewAuth } from './preview-auth.js';
10
10
  import { versionReachedAt } from './scope-deployment.js';
11
11
  import { reconcileConnectionGrants } from './connection-grants.js';
12
+ import { reconcileThenReassert } from './reconcile.js';
12
13
  import { ConnectionRelayError, relayConnectionUpsert } from './connection-relay.js';
13
14
  import { ControlPlaneError } from './client.js';
14
15
  import { provisionSiblingScope } from './platform-drain.js';
@@ -56,6 +57,19 @@ function outsideTenant(p, tenantId) {
56
57
  const pin = confinedTenant(p);
57
58
  return pin !== null && pin !== tenantId;
58
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
+ }
59
73
  // -- request schemas ---------------------------------------------------------
60
74
  // Parse, don't trust: every input crosses Zod at the boundary. The ids stay
61
75
  // CALLER-SUPPLIED rather than minted here, exactly as the contract has them —
@@ -371,6 +385,19 @@ const tenantRoleAssignmentBody = z
371
385
  * spells out; `.strict()` so a body that tried to name one is refused, not stripped.
372
386
  */
373
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
+ });
374
401
  /** The peer kill switch's body (#1706) — the tenant and scope come from the path. */
375
402
  const peerSwitchBody = peerSwitch.pick({ vertical: true, reason: true }).strict();
376
403
  /** A new org under an addressed tenant (#1343) — `tenantId` comes from the path. */
@@ -453,6 +480,7 @@ const TENANT_ROUTES = [
453
480
  { method: 'DELETE', re: /\/verticals\/[^/]+$/, pin: 'owner' },
454
481
  { method: 'GET', re: /\/verticals\/[^/]+\/versions$/, pin: 'owner' },
455
482
  { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/registry$/, pin: 'owner' },
483
+ { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/migrations$/, pin: 'owner' },
456
484
  { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/model$/, pin: 'owner' },
457
485
  { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/flow$/, pin: 'owner' },
458
486
  { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/assets$/, pin: 'owner' },
@@ -460,6 +488,7 @@ const TENANT_ROUTES = [
460
488
  { method: 'GET', re: /\/verticals\/[^/]+\/channels$/, pin: 'owner' },
461
489
  { method: 'GET', re: /\/verticals\/[^/]+\/channels\/[^/]+\/history$/, pin: 'owner' },
462
490
  { method: 'POST', re: /\/verticals\/[^/]+\/channels\/[^/]+\/promote$/, pin: 'owner' },
491
+ { method: 'GET', re: /\/verticals\/[^/]+\/channels\/[^/]+\/promote-impact$/, pin: 'owner' },
463
492
  { method: 'GET', re: /\/verticals\/[^/]+\/previews$/, pin: 'owner' },
464
493
  { method: 'POST', re: /\/verticals\/[^/]+\/previews$/, pin: 'owner' },
465
494
  { method: 'DELETE', re: /\/verticals\/[^/]+\/previews\/[^/]+$/, pin: 'owner' },
@@ -612,6 +641,9 @@ export function createControlPlaneApi(options) {
612
641
  // The declared permission registry of one version (D-39, #336) — owner-narrowed in the
613
642
  // handler like the versions list; the builder-facing Permissions tab reads it.
614
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$/ },
615
647
  // The emitted entity model of one version (#1214) — owner-narrowed the same way;
616
648
  // the dashboard's Model tab reads it.
617
649
  { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/model$/ },
@@ -1587,8 +1619,13 @@ export function createControlPlaneApi(options) {
1587
1619
  if (scope.kind === 'preview') {
1588
1620
  throw new ControlPlaneError(409, 'preview scopes cannot adopt the production serving script');
1589
1621
  }
1590
- if (scope.servingRef)
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 });
1591
1627
  return { servingRef: scope.servingRef, alreadyAdopted: true };
1628
+ }
1592
1629
  if (!scope.vertical) {
1593
1630
  throw new ControlPlaneError(409, 'scope has no vertical — nothing to adopt onto');
1594
1631
  }
@@ -1606,6 +1643,9 @@ export function createControlPlaneApi(options) {
1606
1643
  // Data landed — only now flip routing and move the version pointer.
1607
1644
  await admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref);
1608
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 });
1609
1649
  return { servingRef: serving.ref, tables: restored.tables };
1610
1650
  };
1611
1651
  /**
@@ -1677,6 +1717,8 @@ export function createControlPlaneApi(options) {
1677
1717
  throw new ControlPlaneError(409, `vertical '${target}' has no serving script yet — promote a version to prod first`);
1678
1718
  }
1679
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 });
1680
1722
  return { servingRef: serving.ref, versionId: serving.versionId, alreadyBound: true };
1681
1723
  }
1682
1724
  if (opts.abandonData) {
@@ -1715,6 +1757,8 @@ export function createControlPlaneApi(options) {
1715
1757
  // pre-migration state, and it is never deleted — that copy is the backout.
1716
1758
  await admin.setScopeServingRef(actor, tenantId, scopeId, serving.ref);
1717
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 });
1718
1762
  return { servingRef: serving.ref, versionId: serving.versionId, tables: restored.tables };
1719
1763
  };
1720
1764
  /**
@@ -2034,7 +2078,8 @@ export function createControlPlaneApi(options) {
2034
2078
  : null;
2035
2079
  const ranAs = versionReachedAt(reached.via, scope, serving);
2036
2080
  try {
2037
- 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({
2038
2083
  tenantId,
2039
2084
  scopeId,
2040
2085
  entitlements,
@@ -2045,7 +2090,7 @@ export function createControlPlaneApi(options) {
2045
2090
  // by the vertical, so the reconcile must carry it into the same ready-gate — a bound
2046
2091
  // but unmigrated database fails as loudly as an absent one.
2047
2092
  ...(tenantStores.length ? { tenantStores } : {}),
2048
- });
2093
+ }));
2049
2094
  // #1172: the reconcile succeeded, so record WHICH version it ran against. Without
2050
2095
  // this the sweep would come back and do it again on the next pass — the console
2051
2096
  // button and the automatic phase have to write the same receipt, or pressing the
@@ -3090,6 +3135,9 @@ export function createControlPlaneApi(options) {
3090
3135
  const vertical = await verticalForScope(c, scope);
3091
3136
  if (vertical)
3092
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 });
3093
3141
  return c.json({ restored: scopeId, tables: tables.length });
3094
3142
  }
3095
3143
  catch (e) {
@@ -3158,10 +3206,15 @@ export function createControlPlaneApi(options) {
3158
3206
  force,
3159
3207
  localApply: !vertical,
3160
3208
  });
3161
- if (vertical) {
3162
- return c.json(await vertical.rewindScope(scopeId, bookmark, { force }));
3163
- }
3164
- 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);
3165
3218
  }
3166
3219
  catch (e) {
3167
3220
  if (e instanceof ControlPlaneError) {
@@ -3275,6 +3328,12 @@ export function createControlPlaneApi(options) {
3275
3328
  if (scope)
3276
3329
  await carryOntoVersion(c, scope, versionId);
3277
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
+ };
3278
3337
  if (snapshot) {
3279
3338
  if (!scope) {
3280
3339
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
@@ -3300,10 +3359,12 @@ export function createControlPlaneApi(options) {
3300
3359
  await carry();
3301
3360
  await admin.bindScopeVersion(actor, tenantId, scopeId, versionId, { snapshot: true });
3302
3361
  }
3362
+ await reassert();
3303
3363
  return c.json(await admin.getScopeRecord(actor, tenantId, scopeId));
3304
3364
  }
3305
3365
  await carry();
3306
3366
  await admin.bindScopeVersion(actor, tenantId, scopeId, versionId);
3367
+ await reassert();
3307
3368
  return c.json(await admin.getScopeRecord(actor, tenantId, scopeId));
3308
3369
  });
3309
3370
  // -- instances (K-31) -------------------------------------------------------
@@ -3405,6 +3466,14 @@ export function createControlPlaneApi(options) {
3405
3466
  // one guessed from the serving pointer would claim a hook ran against code this
3406
3467
  // scope was not running.
3407
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
+ }
3408
3477
  if (provisioned?.verticalVersionId) {
3409
3478
  await admin.markScopeProvisioned(c.get('actor'), input.tenantId, input.scopeId, provisioned.verticalVersionId);
3410
3479
  }
@@ -3680,6 +3749,31 @@ export function createControlPlaneApi(options) {
3680
3749
  const registry = json ? (storedDeployManifest.parse(JSON.parse(json)).registry ?? null) : null;
3681
3750
  return c.json({ registry });
3682
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
+ });
3683
3777
  // The emitted entity model (#1214) one version ships: entities, field schemas, parent
3684
3778
  // edges, declared lifecycles — read out of the same retained manifest as the registry
3685
3779
  // above, and owner-narrowed the same way. `model` is null for a version pushed by a
@@ -4024,6 +4118,25 @@ export function createControlPlaneApi(options) {
4024
4118
  return { minted: [], error: message };
4025
4119
  }
4026
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
+ });
4027
4140
  app.post('/verticals/:slug/channels/:channel/promote', async (c) => {
4028
4141
  const p = c.get('principal');
4029
4142
  const slug = await resolveVerticalId(c, c.req.param('slug'));
@@ -4060,10 +4173,37 @@ export function createControlPlaneApi(options) {
4060
4173
  }
4061
4174
  }
4062
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;
4063
4190
  // The blast-radius moment: refuses a changed digest without the acknowledgement,
4064
4191
  // and refuses a non-admitted version. Both are enforced below the seam and
4065
4192
  // surface as a 4xx through mapError, not a 500.
4066
- 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
+ }
4067
4207
  // The in-place serve (#286), prod only, AFTER every promote gate has passed —
4068
4208
  // uploading first would deploy to live scopes before the acknowledgement check.
4069
4209
  // A failed serve is NOT a failed promote: the channel moved (audited), old code
@@ -4103,12 +4243,11 @@ export function createControlPlaneApi(options) {
4103
4243
  // report must not be the one place that hands them the tenant ids of everyone who
4104
4244
  // installed their vertical. The rest is a count, which tells them the fleet was covered
4105
4245
  // without naming who is in it.
4106
- const p2 = c.get('principal');
4107
- const reportPin = confinedTenant(p2);
4108
- const visible = reportPin ? backfill.minted.filter((m) => m.tenantId === reportPin) : backfill.minted;
4109
- 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')));
4110
4247
  return c.json({
4111
4248
  ...promoted,
4249
+ // What the acknowledged break reached, so a promoter who passed `exportBreak` reads it.
4250
+ ...(exportBreaks ? { exportBreaks } : {}),
4112
4251
  ...(backfill.minted.length || backfill.error
4113
4252
  ? {
4114
4253
  storeBackfill: {
@@ -4566,6 +4705,8 @@ export function createControlPlaneApi(options) {
4566
4705
  // than somebody else's, and needs no ownership check of its own to be safe.
4567
4706
  scopeId: z.string().min(1).max(64).optional(),
4568
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(),
4569
4710
  hours: z.coerce.number().int().min(1).max(72).default(24),
4570
4711
  since: z.string().datetime({ offset: true }).optional(),
4571
4712
  until: z.string().datetime({ offset: true }).optional(),
@@ -4574,6 +4715,7 @@ export function createControlPlaneApi(options) {
4574
4715
  tenantId,
4575
4716
  scopeId: c.req.query('scopeId') || undefined,
4576
4717
  vertical: c.req.query('vertical') || undefined,
4718
+ grain: c.req.query('grain') || undefined,
4577
4719
  hours: c.req.query('hours'), since: c.req.query('since'), until: c.req.query('until'),
4578
4720
  });
4579
4721
  try {
@@ -5240,6 +5382,8 @@ export function createControlPlaneApi(options) {
5240
5382
  if (existing.servingRef)
5241
5383
  await admin.setScopeServingRef(actor, tenantId, existing.id, null);
5242
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 });
5243
5387
  // Renew (or clear) the preview's GC deadline so a reused preview does not silently die.
5244
5388
  await admin.setScopeExpiresAt(actor, tenantId, existing.id, expiresAt);
5245
5389
  const hostname = await bindPreviewHostname(actor, baseHostname, tenantId, existing.id, opts.tag, surface);
@@ -5599,6 +5743,36 @@ export function createControlPlaneApi(options) {
5599
5743
  }
5600
5744
  return c.json(await admin.systemGrantsStatus(actor, { tenantId, scopeId }));
5601
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
+ });
5602
5776
  // -- the peer kill switch (#1706) ------------------------------------------
5603
5777
  //
5604
5778
  // One calling vertical's access to ONE scope, off and back on — `revokeFromPeer` and
@@ -5721,6 +5895,51 @@ export function createControlPlaneApi(options) {
5721
5895
  const entries = await admin.listSweepRuns(c.get('actor'), filter);
5722
5896
  return c.json(pageOf(entries, filter.limit, (e) => e.id));
5723
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
+ });
5724
5943
  // -- issues (#1233): failures grouped by fingerprint, with a lifecycle --------
5725
5944
  // Staff-only (absent from BUILDER_ROUTES): an issue is a fleet-scoped aggregate
5726
5945
  // with no tenant column, so the forced-filter posture cannot narrow it — a