@substrat-run/control-plane-api 0.138.0 → 0.140.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/api.d.ts +14 -1
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +398 -49
  4. package/dist/api.js.map +1 -1
  5. package/dist/audited-change.d.ts +139 -0
  6. package/dist/audited-change.d.ts.map +1 -0
  7. package/dist/audited-change.js +214 -0
  8. package/dist/audited-change.js.map +1 -0
  9. package/dist/connect-links.d.ts +29 -0
  10. package/dist/connect-links.d.ts.map +1 -0
  11. package/dist/connect-links.js +139 -0
  12. package/dist/connect-links.js.map +1 -0
  13. package/dist/connect-url.d.ts +20 -1
  14. package/dist/connect-url.d.ts.map +1 -1
  15. package/dist/connect-url.js +1 -1
  16. package/dist/connect-url.js.map +1 -1
  17. package/dist/connection-relay.d.ts +24 -0
  18. package/dist/connection-relay.d.ts.map +1 -1
  19. package/dist/connection-relay.js +31 -12
  20. package/dist/connection-relay.js.map +1 -1
  21. package/dist/email-intent.d.ts +71 -0
  22. package/dist/email-intent.d.ts.map +1 -0
  23. package/dist/email-intent.js +186 -0
  24. package/dist/email-intent.js.map +1 -0
  25. package/dist/index.d.ts +7 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +6 -3
  28. package/dist/index.js.map +1 -1
  29. package/dist/platform-drain.d.ts +12 -2
  30. package/dist/platform-drain.d.ts.map +1 -1
  31. package/dist/platform-drain.js +15 -3
  32. package/dist/platform-drain.js.map +1 -1
  33. package/dist/reconcile.d.ts +17 -2
  34. package/dist/reconcile.d.ts.map +1 -1
  35. package/dist/reconcile.js +35 -1
  36. package/dist/reconcile.js.map +1 -1
  37. package/dist/staff-client.d.ts +3 -1
  38. package/dist/staff-client.d.ts.map +1 -1
  39. package/dist/staff-client.js +2 -0
  40. package/dist/staff-client.js.map +1 -1
  41. package/dist/vertical-client.d.ts +60 -2
  42. package/dist/vertical-client.d.ts.map +1 -1
  43. package/dist/vertical-client.js +111 -20
  44. package/dist/vertical-client.js.map +1 -1
  45. package/package.json +6 -6
package/dist/api.js CHANGED
@@ -1,15 +1,16 @@
1
1
  import { resolveObservabilityWindow } from './observability-window.js';
2
2
  import { Hono } from 'hono';
3
- import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ownerTransferInput, OWNER_TRANSFER_AUDIT_ERROR_MAX, 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, impersonationStamp, promotionAcknowledgement, bindAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, lifecycleFlowInput, operationSeriesCount, operationSeriesInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, peerSwitch, systemSwitch, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, denialGroupBy, registerVerticalInput, platformScriptCollision, platformScriptRefusal, PLATFORM_SCRIPT_NAMES, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, errorCodeOf, capabilityFilterQuery, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, } from '@substrat-run/contracts';
3
+ import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ownerTransferInput, memberInviteInput, memberRoleInput, ASSET_PART_PREFIX, assetHash, channelName, connectLinkId, connectionActivity, connectionActivitySource, connectionCredential, connectionCertificate, connectionFilter, connectionHealthState, connectionProvider, connectorDispatchKind, PROVISION_SIBLING_KIND, ARCHIVE_SCOPE_KIND, PROVISION_TENANT_KIND, SET_ENTITLEMENTS_KIND, MODEL_USAGE_KIND, SWEEP_RUNS_KIND, toConnectionHealthEntry, CONNECTION_EXPIRY_WARNING_DAYS, CONNECTION_STALE_DAYS, connectionProbe, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, errorCode, issueStatus, issueStatusInput, findingKind, findingRuleInput, findingStatus, findingStatusInput, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, impersonationStamp, promotionAcknowledgement, bindAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, lifecycleFlowInput, operationSeriesCount, operationSeriesInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, peerSwitch, verticalSlug, systemSwitch, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, denialGroupBy, registerVerticalInput, platformScriptCollision, platformScriptRefusal, PLATFORM_SCRIPT_NAMES, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, errorCodeOf, capabilityFilterQuery, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, } from '@substrat-run/contracts';
4
4
  import { attributeFailure } from './failure-attribution.js';
5
- import { BIND_EXPORT_BREAK_REFUSAL, CARRIED_AWAY_KEY, bindExportBreakRefusal, carriedAwayDump, dumpMetaValue, metaValueIn, crossVerticalHealth, exportBreakRefusal, isBindExportBreakRefusal, isExportBreakRefusal, isPrimaryScope, migrationProgress, ulid, } from '@substrat-run/kernel';
5
+ import { auditedChange, withAuditedOutcomes } from './audited-change.js';
6
+ import { BIND_EXPORT_BREAK_REFUSAL, CARRIED_AWAY_KEY, bindExportBreakRefusal, carriedAwayDump, dumpMetaValue, metaValueIn, crossVerticalHealth, exportBreakRefusal, isBindExportBreakRefusal, isExportBreakRefusal, isPrimaryScope, migrationProgress, ulid, auditWarningOf, } from '@substrat-run/kernel';
6
7
  import { TENANT_HEADER, confinedTenant } from './auth.js';
7
8
  import { mintTenantToken } from './tenant-token.js';
8
9
  import { connectionGrantsForScope } from './vertical-client.js';
9
10
  import { oidcCallbackUrl, retireClientsOfReapedScope, wirePreviewAuth } from './preview-auth.js';
10
11
  import { versionReachedAt } from './scope-deployment.js';
11
12
  import { reconcileConnectionGrants } from './connection-grants.js';
12
- import { reconcileThenReassert, switchCarryFor } from './reconcile.js';
13
+ import { reconcileThenReassert, reviewedEntityGrants, switchCarryFor } from './reconcile.js';
13
14
  import { ConnectionRelayError, relayConnectionUpsert } from './connection-relay.js';
14
15
  import { ControlPlaneError } from '@substrat-run/control-plane-client';
15
16
  import { provisionSiblingScope } from './platform-drain.js';
@@ -478,6 +479,22 @@ const issueStatusUpdate = z.object({
478
479
  fingerprint: z.string().min(1),
479
480
  status: issueStatusInput,
480
481
  });
482
+ // The findings read (#1748). No cursor, for the issues read's reason: the cardinality is the
483
+ // number of distinct shapes a tenant has. `tenantId` is forced for a confined principal.
484
+ const findingsQuery = z.object({
485
+ tenantId: tenantIdSchema.optional(),
486
+ status: findingStatus.optional(),
487
+ kind: findingKind.optional(),
488
+ limit: z.coerce.number().int().min(1).max(500).optional(),
489
+ });
490
+ const findingStatusUpdate = z.object({ status: findingStatusInput }).strict();
491
+ const findingRulesQuery = z.object({
492
+ active: z
493
+ .enum(['true', 'false'])
494
+ .optional()
495
+ .transform((v) => v === 'true'),
496
+ limit: z.coerce.number().int().min(1).max(500).optional(),
497
+ });
481
498
  /**
482
499
  * A TENANT-LEVEL role assignment under an addressed tenant (#1343) — which is
483
500
  * what a team membership is, and all this surface does.
@@ -579,7 +596,16 @@ const TENANT_ROUTES = [
579
596
  { method: 'DELETE', re: /\/tenants\/[^/]+\/connections\/[^/]+$/, pin: 'path' },
580
597
  { method: 'GET', re: /\/tenants\/[^/]+\/connections\/[^/]+\/activity$/, pin: 'path' },
581
598
  { method: 'GET', re: /\/tenants\/[^/]+\/connections\/[^/]+\/credential$/, pin: 'path' },
599
+ // #2100: the PUBLIC certificate a connection signs in with — same pin as the credential view.
600
+ { method: 'GET', re: /\/tenants\/[^/]+\/connections\/[^/]+\/certificate$/, pin: 'path' },
582
601
  { method: 'POST', re: /\/tenants\/[^/]+\/connections\/[^/]+\/verify$/, pin: 'path' },
602
+ // A vertical's mailed connect links (connections.md §3.5.4): the consent landing's liveness
603
+ // read, the callback's spend and its undo, and the integrations card's list and revoke.
604
+ { method: 'GET', re: /\/tenants\/[^/]+\/connect-links$/, pin: 'path' },
605
+ { method: 'GET', re: /\/tenants\/[^/]+\/connect-links\/[^/]+$/, pin: 'path' },
606
+ { method: 'POST', re: /\/tenants\/[^/]+\/connect-links\/[^/]+\/consume$/, pin: 'path' },
607
+ { method: 'POST', re: /\/tenants\/[^/]+\/connect-links\/[^/]+\/restore$/, pin: 'path' },
608
+ { method: 'DELETE', re: /\/tenants\/[^/]+\/connect-links\/[^/]+$/, pin: 'path' },
583
609
  // Members: identity links mirrored from the dashboard, orgs, role assignments.
584
610
  { method: 'GET', re: /\/tenants\/[^/]+\/identities$/, pin: 'path' },
585
611
  { method: 'PUT', re: /\/tenants\/[^/]+\/identities$/, pin: 'path' },
@@ -592,6 +618,12 @@ const TENANT_ROUTES = [
592
618
  // grant (`tenantGrantableEntitlement`): the pin alone would let it grant itself any.
593
619
  { method: 'PUT', re: /\/tenants\/[^/]+\/entitlements\/[^/]+$/, pin: 'path' },
594
620
  { method: 'GET', re: /\/tenants\/[^/]+\/cross-vertical\/edges$/, pin: 'path' },
621
+ // Findings (#1748): the tenant's inbox, its verdicts and its suppress rules. The dashboard
622
+ // checks WHO inside the tenant may read or act (its findings permissions) before calling.
623
+ { method: 'PUT', re: /\/tenants\/[^/]+\/findings\/[^/]+\/status$/, pin: 'path' },
624
+ { method: 'GET', re: /\/tenants\/[^/]+\/finding-rules$/, pin: 'path' },
625
+ { method: 'POST', re: /\/tenants\/[^/]+\/finding-rules$/, pin: 'path' },
626
+ { method: 'DELETE', re: /\/tenants\/[^/]+\/finding-rules\/[^/]+$/, pin: 'path' },
595
627
  // Scopes: provision, read, and the lifecycle acts the Data and Deployments tabs offer.
596
628
  { method: 'POST', re: /\/tenants\/[^/]+\/scopes$/, pin: 'path' },
597
629
  { method: 'GET', re: /\/tenants\/[^/]+\/scopes\/[^/]+$/, pin: 'path' },
@@ -611,6 +643,8 @@ const TENANT_ROUTES = [
611
643
  { method: 'GET', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/migrations$/, pin: 'path' },
612
644
  { method: 'GET', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/owner-seat$/, pin: 'path' },
613
645
  { method: 'GET', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/peer-grants$/, pin: 'path' },
646
+ { method: 'GET', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/peer-bindings$/, pin: 'path' },
647
+ { method: 'PUT', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/peer-bindings$/, pin: 'path' },
614
648
  // The peer switch (#1706): on is POST, off is DELETE, on the tenant's own scope.
615
649
  { method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/peer-grants$/, pin: 'path' },
616
650
  { method: 'DELETE', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/peer-grants$/, pin: 'path' },
@@ -624,6 +658,11 @@ const TENANT_ROUTES = [
624
658
  { method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/lifecycle-flow$/, pin: 'path' },
625
659
  { method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/operation-series$/, pin: 'path' },
626
660
  { method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/owner-claim$/, pin: 'path' },
661
+ // An installed vertical's members (#1150): the roster, an invite, a role move, a removal.
662
+ { method: 'GET', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/members$/, pin: 'path' },
663
+ { method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/members$/, pin: 'path' },
664
+ { method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/members\/[^/]+\/role$/, pin: 'path' },
665
+ { method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/members\/[^/]+\/remove$/, pin: 'path' },
627
666
  { method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/query$/, pin: 'path' },
628
667
  { method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/reap$/, pin: 'path' },
629
668
  { method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/rebind-vertical$/, pin: 'path' },
@@ -652,6 +691,7 @@ const TENANT_ROUTES = [
652
691
  { method: 'GET', re: /\/admin-log$/, pin: 'query' },
653
692
  { method: 'GET', re: /\/ops-failures$/, pin: 'query' },
654
693
  { method: 'GET', re: /\/sweep-runs$/, pin: 'query' },
694
+ { method: 'GET', re: /\/findings$/, pin: 'query' },
655
695
  { method: 'GET', re: /\/service-refs$/, pin: 'query' },
656
696
  { method: 'GET', re: /\/observability\/tenant-metrics$/, pin: 'query' },
657
697
  { method: 'GET', re: /\/observability\/tenant-metrics-series$/, pin: 'query' },
@@ -1560,7 +1600,7 @@ export function createControlPlaneApi(options) {
1560
1600
  return c.json({ error: 'body tenantId disagrees with the route tenant' }, 400);
1561
1601
  }
1562
1602
  try {
1563
- return c.json(await relayConnectionUpsert(c.var.host, c.get('actor'), { ...body, tenantId }, { probeCandidate }));
1603
+ return c.json(await relayConnectionUpsert(c.var.host, c.get('actor'), { ...body, tenantId }, { probeCandidate, prepareCandidate }));
1564
1604
  }
1565
1605
  catch (err) {
1566
1606
  if (err instanceof ConnectionRelayError && err.status < 500) {
@@ -1590,6 +1630,80 @@ export function createControlPlaneApi(options) {
1590
1630
  await c.var.admin.revokeConnection(c.get('actor'), row.id);
1591
1631
  return c.body(null, 204);
1592
1632
  });
1633
+ // -- a vertical's mailed connect links, tenant-scoped (connections.md §3.5.4) ---------
1634
+ //
1635
+ // The dashboard's half of a link a VERTICAL minted (`/internal/connections/connect-links`):
1636
+ // its consent landing asks whether the row still stands, its callback spends the row
1637
+ // before storing and restores it if the store fails, and its integrations card lists and
1638
+ // revokes them. The tenant is the path's; the scope is named on every call and is half
1639
+ // the store's key, so another scope's link — or another tenant's — reads as absent (K-3).
1640
+ // Not in BUILDER_ROUTES: a builder has no business spending a tenant's consent round.
1641
+ const connectLinkScope = z.object({ scopeId: scopeIdSchema });
1642
+ app.get('/tenants/:tenantId/connect-links', async (c) => {
1643
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1644
+ const q = connectLinkScope
1645
+ .extend({ provider: connectionProvider.optional(), outstanding: z.literal('1').optional() })
1646
+ .parse({
1647
+ scopeId: c.req.query('scopeId'),
1648
+ provider: c.req.query('provider') || undefined,
1649
+ outstanding: c.req.query('outstanding') || undefined,
1650
+ });
1651
+ return c.json(await c.var.admin.listConnectLinks(c.get('actor'), {
1652
+ tenantId,
1653
+ scopeId: q.scopeId,
1654
+ ...(q.provider ? { provider: q.provider } : {}),
1655
+ ...(q.outstanding ? { outstandingOnly: true } : {}),
1656
+ }));
1657
+ });
1658
+ app.get('/tenants/:tenantId/connect-links/:linkId', async (c) => {
1659
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1660
+ const { scopeId } = connectLinkScope.parse({ scopeId: c.req.query('scopeId') });
1661
+ const link = await c.var.admin.getConnectLink(c.get('actor'), {
1662
+ tenantId,
1663
+ scopeId,
1664
+ id: connectLinkId.parse(c.req.param('linkId')),
1665
+ });
1666
+ return link ? c.json(link) : c.json({ error: 'unknown connect link' }, 404);
1667
+ });
1668
+ // The callback's spend. A refusal is a 200 carrying the typed reason, not an error status:
1669
+ // "already used" is the answer to the question asked, and the callback renders each one.
1670
+ app.post('/tenants/:tenantId/connect-links/:linkId/consume', async (c) => {
1671
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1672
+ const body = connectLinkScope
1673
+ .extend({
1674
+ provider: connectionProvider,
1675
+ accountRef: z.string().max(256).optional(),
1676
+ accountLabel: z.string().max(256).optional(),
1677
+ })
1678
+ .parse(await c.req.json());
1679
+ return c.json(await c.var.admin.consumeConnectLink(c.get('actor'), {
1680
+ ...body,
1681
+ tenantId,
1682
+ id: connectLinkId.parse(c.req.param('linkId')),
1683
+ }));
1684
+ });
1685
+ app.post('/tenants/:tenantId/connect-links/:linkId/restore', async (c) => {
1686
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1687
+ const { scopeId } = connectLinkScope.parse(await c.req.json());
1688
+ const restored = await c.var.admin.restoreConnectLink(c.get('actor'), {
1689
+ tenantId,
1690
+ scopeId,
1691
+ id: connectLinkId.parse(c.req.param('linkId')),
1692
+ });
1693
+ return c.json({ restored });
1694
+ });
1695
+ // Idempotent, as the store is: a revoked or spent link answers 204 again. 404 only for a
1696
+ // link this scope never held.
1697
+ app.delete('/tenants/:tenantId/connect-links/:linkId', async (c) => {
1698
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1699
+ const { scopeId } = connectLinkScope.parse({ scopeId: c.req.query('scopeId') });
1700
+ const link = await c.var.admin.revokeConnectLink(c.get('actor'), {
1701
+ tenantId,
1702
+ scopeId,
1703
+ id: connectLinkId.parse(c.req.param('linkId')),
1704
+ });
1705
+ return link ? c.body(null, 204) : c.json({ error: 'unknown connect link' }, 404);
1706
+ });
1593
1707
  /**
1594
1708
  * The tenant-scoped connection lookup the two inspection routes share. Same K-3
1595
1709
  * existence hiding as the revoke above — a foreign tenant's connection id reads
@@ -1602,6 +1716,8 @@ export function createControlPlaneApi(options) {
1602
1716
  * in which case the upsert behaves exactly as it always did, storing unverified.
1603
1717
  */
1604
1718
  const probeCandidate = async (provider, secret) => options.connectionInspectors?.[provider]?.probeCandidate?.(secret);
1719
+ /** #2100: the provider's own half of a credential, made before the probe sees it. */
1720
+ const prepareCandidate = (provider) => options.connectionInspectors?.[provider]?.prepareCandidate;
1605
1721
  const inspectableConnection = async (c) => {
1606
1722
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1607
1723
  const id = c.req.param('id');
@@ -1675,6 +1791,20 @@ export function createControlPlaneApi(options) {
1675
1791
  }
1676
1792
  return c.json(connectionCredential.parse(await credential(c.var.host, row)));
1677
1793
  });
1794
+ /**
1795
+ * The public certificate a connection signs in with (#2100) — what the tenant downloads and
1796
+ * uploads to the provider. Public by construction: the private key never leaves the sealed
1797
+ * credential, and only a provider whose keypair the platform generates has one at all.
1798
+ */
1799
+ app.get('/tenants/:tenantId/connections/:id/certificate', async (c) => {
1800
+ const row = await inspectableConnection(c);
1801
+ if (!row)
1802
+ return c.json({ error: 'unknown connection' }, 404);
1803
+ const certificate = await options.connectionInspectors?.[row.provider]?.certificate?.(c.var.host, row);
1804
+ if (!certificate)
1805
+ return c.json({ error: 'this connection signs in with no certificate' }, 404);
1806
+ return c.json(connectionCertificate.parse(certificate));
1807
+ });
1678
1808
  // -- the scope directory (§3.2/§4.2) ---------------------------------------
1679
1809
  app.get('/scopes', async (c) => {
1680
1810
  const p = c.get('principal');
@@ -2797,6 +2927,9 @@ export function createControlPlaneApi(options) {
2797
2927
  ? await c.var.admin.verticalServing(actor, scope.vertical).catch(() => null)
2798
2928
  : null;
2799
2929
  const ranAs = versionReachedAt(reached.via, scope, serving);
2930
+ // #2071: the shapes this reconcile tops holders up to, from the reviewed registry of the
2931
+ // version it reaches — never anything the vertical's own code names.
2932
+ const entityGrants = scope.vertical && ranAs ? await reviewedEntityGrants(c.var.admin, actor, scope.vertical, ranAs) : undefined;
2800
2933
  try {
2801
2934
  // #1674: the recorded OFF positions go back after the reconcile, before the receipt.
2802
2935
  const result = await reconcileThenReassert(c.var.admin, actor, { tenantId, scopeId }, (carry) => vertical.reconcileInstance({
@@ -2806,6 +2939,7 @@ export function createControlPlaneApi(options) {
2806
2939
  identityLinks,
2807
2940
  connectionGrants,
2808
2941
  connectionKeys,
2942
+ ...(entityGrants ? { entityGrants } : {}),
2809
2943
  ...carry,
2810
2944
  // Handed over exactly as at provision: a store minted HERE has never been migrated
2811
2945
  // by the vertical, so the reconcile must carry it into the same ready-gate — a bound
@@ -3158,6 +3292,19 @@ export function createControlPlaneApi(options) {
3158
3292
  throw e;
3159
3293
  }
3160
3294
  });
3295
+ /**
3296
+ * The address a link into an instance opens on: the scope's canonical `app` hostname,
3297
+ * preferring one the platform has activated but not requiring it — a platform hostname is
3298
+ * bound `pending` and activated a step later, and that step can fail while the app is fully
3299
+ * reachable (#294). `failed` is the one state that never routes, so only it is skipped. The
3300
+ * platform owns the hostname directory, so the origin is never taken from a body.
3301
+ */
3302
+ const instanceOrigin = async (admin, actor, scopeId) => {
3303
+ const bound = (await admin.listHostnames(actor, { scopeId })).filter((h) => h.status !== 'failed');
3304
+ const pick = (hs) => hs.find((h) => h.surface === 'app' && h.canonical) ?? hs.find((h) => h.surface === 'app') ?? hs[0];
3305
+ const host = pick(bound.filter((h) => h.status === 'active')) ?? pick(bound);
3306
+ return host ? `https://${host.hostname}` : null;
3307
+ };
3161
3308
  app.post('/tenants/:tenantId/scopes/:scopeId/owner-claim', async (c) => {
3162
3309
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
3163
3310
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
@@ -3172,18 +3319,97 @@ export function createControlPlaneApi(options) {
3172
3319
  const vertical = await verticalForScope(c, scope);
3173
3320
  if (!vertical)
3174
3321
  return c.json({ error: await diagnoseUnboundScope(actor, scope) }, 501);
3175
- // The address the link opens on: the scope's canonical `app` hostname, preferring one
3176
- // the platform has activated but not requiring it — a platform hostname is bound
3177
- // `pending` and activated a step later, and that step can fail while the app is fully
3178
- // reachable (#294). `failed` is the one state that never routes, so only it is skipped.
3179
- const bound = (await c.var.admin.listHostnames(actor, { scopeId })).filter((h) => h.status !== 'failed');
3180
- const pick = (hs) => hs.find((h) => h.surface === 'app' && h.canonical) ?? hs.find((h) => h.surface === 'app') ?? hs[0];
3181
- const host = pick(bound.filter((h) => h.status === 'active')) ?? pick(bound);
3182
- if (!host) {
3322
+ const origin = await instanceOrigin(c.var.admin, actor, scopeId);
3323
+ if (!origin) {
3183
3324
  return c.json({ error: `scope ${scopeId} has no hostname bound — a claim link needs an address to open on` }, 409);
3184
3325
  }
3185
3326
  try {
3186
- return c.json(await vertical.mintOwnerClaim({ tenantId, scopeId, origin: `https://${host.hostname}`, actor }), 201);
3327
+ return c.json(await vertical.mintOwnerClaim({ tenantId, scopeId, origin, actor }), 201);
3328
+ }
3329
+ catch (e) {
3330
+ if (e instanceof ControlPlaneError)
3331
+ return c.json({ error: e.message }, e.status);
3332
+ throw e;
3333
+ }
3334
+ });
3335
+ // An installed vertical's MEMBERS (#1150) — the dashboard's Members section. The roster and
3336
+ // every change live in the VERTICAL (its scope's role tuples, its identity directory), so each
3337
+ // route makes the same K-3 cross-check as the owner seat and then asks the deployment that
3338
+ // holds the scope. One scope per call: no walk across scopes (#1581).
3339
+ //
3340
+ // **Who is acting is the credential's person, and only that.** Every change is bounded in the
3341
+ // vertical by the K-21 set comparison at the vertical's scope, asked about a principal — and
3342
+ // the one principal this plane can vouch for is the `onBehalfOf` the dashboard's tenant token
3343
+ // carries (#977), which only the staff mint writes. A builder session, a staff token or a
3344
+ // tenant token minted for nobody names no person, so the writes refuse it rather than
3345
+ // borrowing an authority it does not have. Being a tenant (dashboard) admin confers nothing in
3346
+ // the vertical's scope: what the person holds THERE is what bounds them.
3347
+ //
3348
+ // Every change is audited intent-first then outcome (`manageScopeMember`), like the hand-over.
3349
+ const memberPerson = (p) => p.kind === 'tenant' && p.onBehalfOf ? p.onBehalfOf.principal : null;
3350
+ /** The scope, its vertical and the acting person, or the answer that refuses the request. */
3351
+ const memberTarget = async (c) => {
3352
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
3353
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
3354
+ const principal = c.get('principal');
3355
+ const actor = c.get('actor');
3356
+ // K-3: another tenant's pair, or no such scope, reads the same — absent.
3357
+ const scope = outsideTenant(principal, tenantId) ? null : await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
3358
+ if (!scope)
3359
+ return { refusal: c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404) };
3360
+ const vertical = await verticalForScope(c, scope);
3361
+ if (!vertical)
3362
+ return { refusal: c.json({ error: await diagnoseUnboundScope(actor, scope) }, 501) };
3363
+ return { tenantId, scopeId, actor, vertical, person: memberPerson(principal) };
3364
+ };
3365
+ /**
3366
+ * Answer for one `auditedChange` (#2064). A refusal or failure answers with the vertical's own
3367
+ * status (a non-`ControlPlaneError` throw is rethrown). A change that went through answers
3368
+ * `ok`, with its `operationId`, whether or not its outcome row could be written. When it could
3369
+ * not, the answer also carries `auditWarning`, which says so. That is a success on purpose:
3370
+ * the change happened, so no client should read a retryable error and make it twice (a second
3371
+ * invite, a second hand-over). The missing row is logged, and the scheduled settle closes it.
3372
+ */
3373
+ const auditedAnswer = (c, done, what, ok) => {
3374
+ const { operationId } = done;
3375
+ if ('error' in done) {
3376
+ if (done.error instanceof ControlPlaneError) {
3377
+ return c.json({ error: done.error.message, operationId }, done.error.status);
3378
+ }
3379
+ throw done.error;
3380
+ }
3381
+ if ('unrecorded' in done) {
3382
+ return ok(done.result, {
3383
+ operationId,
3384
+ auditWarning: auditWarningOf(what, done.unrecorded),
3385
+ });
3386
+ }
3387
+ return ok(done.result, { operationId });
3388
+ };
3389
+ /** Run one audited member change: intent, the vertical's call, then the outcome row. */
3390
+ const auditedMemberChange = (c, entry, run, applied) => {
3391
+ const actor = c.get('actor');
3392
+ const operationId = ulid();
3393
+ return auditedChange({
3394
+ flow: 'member-change',
3395
+ operationId,
3396
+ run,
3397
+ // A 4xx is the vertical's refusal: nothing was written there.
3398
+ refused: (e) => e instanceof ControlPlaneError && e.status < 500,
3399
+ record: (row) => c.var.admin.recordMemberChange(actor, {
3400
+ ...entry,
3401
+ operationId,
3402
+ ...(row.phase === 'applied' ? { phase: 'applied', ...applied(row.result) } : row),
3403
+ }),
3404
+ });
3405
+ };
3406
+ const NO_PERSON = 'a member change needs the signed-in person it is made for — this credential names nobody';
3407
+ app.get('/tenants/:tenantId/scopes/:scopeId/members', async (c) => {
3408
+ const target = await memberTarget(c);
3409
+ if ('refusal' in target)
3410
+ return target.refusal;
3411
+ try {
3412
+ return c.json(await target.vertical.listMembers(target.tenantId, target.scopeId));
3187
3413
  }
3188
3414
  catch (e) {
3189
3415
  if (e instanceof ControlPlaneError)
@@ -3191,6 +3417,44 @@ export function createControlPlaneApi(options) {
3191
3417
  throw e;
3192
3418
  }
3193
3419
  });
3420
+ app.post('/tenants/:tenantId/scopes/:scopeId/members', async (c) => {
3421
+ const target = await memberTarget(c);
3422
+ if ('refusal' in target)
3423
+ return target.refusal;
3424
+ if (!target.person)
3425
+ return c.json({ error: NO_PERSON }, 403);
3426
+ const body = memberInviteInput.parse(await c.req.json());
3427
+ const origin = await instanceOrigin(c.var.admin, target.actor, target.scopeId);
3428
+ if (!origin) {
3429
+ return c.json({ error: `scope ${target.scopeId} has no hostname bound — an invite link needs an address to open on` }, 409);
3430
+ }
3431
+ const { tenantId, scopeId, person } = target;
3432
+ const done = await auditedMemberChange(c, { tenantId, scopeId, change: 'invite', caller: person, roleKey: body.roleKey }, () => target.vertical.inviteMember({ tenantId, scopeId, caller: person, origin, roleKey: body.roleKey, email: body.email ?? null }), (link) => ({ principal: link.principal }));
3433
+ return auditedAnswer(c, done, 'the invite', (link, audit) => c.json({ ...link, ...audit }, 201));
3434
+ });
3435
+ app.post('/tenants/:tenantId/scopes/:scopeId/members/:principal/role', async (c) => {
3436
+ const target = await memberTarget(c);
3437
+ if ('refusal' in target)
3438
+ return target.refusal;
3439
+ if (!target.person)
3440
+ return c.json({ error: NO_PERSON }, 403);
3441
+ const member = principalIdSchema.parse(c.req.param('principal'));
3442
+ const body = memberRoleInput.parse(await c.req.json());
3443
+ const { tenantId, scopeId, person } = target;
3444
+ const done = await auditedMemberChange(c, { tenantId, scopeId, change: 'role', caller: person, principal: member, from: body.from, to: body.to }, () => target.vertical.changeMemberRole({ tenantId, scopeId, caller: person, principal: member, from: body.from, to: body.to }), () => ({}));
3445
+ return auditedAnswer(c, done, 'the role change', (_, audit) => c.json({ principal: member, from: body.from, to: body.to, ...audit }));
3446
+ });
3447
+ app.post('/tenants/:tenantId/scopes/:scopeId/members/:principal/remove', async (c) => {
3448
+ const target = await memberTarget(c);
3449
+ if ('refusal' in target)
3450
+ return target.refusal;
3451
+ if (!target.person)
3452
+ return c.json({ error: NO_PERSON }, 403);
3453
+ const member = principalIdSchema.parse(c.req.param('principal'));
3454
+ const { tenantId, scopeId, person } = target;
3455
+ const done = await auditedMemberChange(c, { tenantId, scopeId, change: 'remove', caller: person, principal: member }, () => target.vertical.removeMember({ tenantId, scopeId, caller: person, principal: member }), (removal) => ({ revoked: removal.revoked }));
3456
+ return auditedAnswer(c, done, 'the removal', (removal, audit) => c.json({ ...removal, ...audit }));
3457
+ });
3194
3458
  // The owner HAND-OVER (#1665). The move runs in the VERTICAL, behind `/internal/owner-transfer`
3195
3459
  // (vertical-host says in what order and why). This route decides who may ask, and audits every
3196
3460
  // attempt: an `intent` row before the vertical is reached, so a row the log cannot take means
@@ -3220,41 +3484,19 @@ export function createControlPlaneApi(options) {
3220
3484
  // An abandon (`abandon: true`) is audited the same way, every row marked, so the log tells
3221
3485
  // a hand-over closed without finishing from one that finished.
3222
3486
  const base = { tenantId, scopeId, operationId, from: body.from, to: body.to, ...(body.abandon ? { abandon: true } : {}) };
3223
- await c.var.admin.recordOwnerTransfer(actor, { ...base, phase: 'intent' });
3224
- let moved;
3225
- try {
3226
- moved = await vertical.transferOwner({ tenantId, scopeId, ...body });
3227
- }
3228
- catch (e) {
3229
- const refused = e instanceof ControlPlaneError && e.status === 409;
3230
- const error = (e instanceof Error ? e.message : String(e)).slice(0, OWNER_TRANSFER_AUDIT_ERROR_MAX);
3231
- await c.var.admin
3232
- .recordOwnerTransfer(actor, { ...base, phase: refused ? 'refused' : 'failed', error })
3233
- .catch(() => undefined);
3234
- if (e instanceof ControlPlaneError) {
3235
- return c.json({ error: e.message, operationId }, e.status);
3236
- }
3237
- throw e;
3238
- }
3239
- try {
3240
- await c.var.admin.recordOwnerTransfer(actor, {
3241
- ...base,
3242
- phase: 'applied',
3243
- outcome: moved.outcome,
3244
- fromRevoked: moved.fromRevoked,
3245
- });
3246
- }
3247
- catch (e) {
3248
- // The owner HAS moved; only the outcome row is missing. Say exactly that, so nobody retries
3249
- // a hand-over believing it failed, and name the operation whose intent row stands alone.
3250
- const why = e instanceof Error ? e.message : String(e);
3251
- return c.json({
3252
- error: `the hand-over completed, but its outcome could not be written to the admin log: ${why}`,
3253
- operationId,
3254
- ...moved,
3255
- }, 500);
3256
- }
3257
- return c.json({ operationId, ...moved });
3487
+ const done = await auditedChange({
3488
+ flow: 'owner-transfer',
3489
+ operationId,
3490
+ run: () => vertical.transferOwner({ tenantId, scopeId, ...body }),
3491
+ // Only the vertical's 409 is a refusal: anything else may have stopped part-way.
3492
+ refused: (e) => e instanceof ControlPlaneError && e.status === 409,
3493
+ record: (row) => c.var.admin.recordOwnerTransfer(actor, row.phase === 'applied'
3494
+ ? { ...base, phase: 'applied', outcome: row.result.outcome, fromRevoked: row.result.fromRevoked }
3495
+ : { ...base, ...row }),
3496
+ });
3497
+ // A missing outcome row is said in `auditWarning` on a success, so nobody retries a hand-over
3498
+ // believing it failed, under the operation whose intent row stands alone.
3499
+ return auditedAnswer(c, done, 'the hand-over', (moved, audit) => c.json({ ...audit, ...moved }));
3258
3500
  });
3259
3501
  // Deliver per-instance CONFIG to the scope's own storage (vertical-auth-detach.md
3260
3502
  // §2.2) — the missing "delivery" step behind the dashboard's Env tab. Same K-3
@@ -6084,6 +6326,9 @@ export function createControlPlaneApi(options) {
6084
6326
  // `emailSender` grant itself is never touched by a push. Refreshes on every re-push
6085
6327
  // like the rest of the install spec, exactly because it grants nothing.
6086
6328
  ...(manifest.sendsEmail ? { sendsEmail: true } : {}),
6329
+ // Where the scope lifecycle is held (#1713): `'router'` keeps the vertical's scopes out of
6330
+ // the lifecycle delivery. Refreshes on every re-push like the rest of the install spec.
6331
+ ...(manifest.lifecycle ? { lifecycle: manifest.lifecycle } : {}),
6087
6332
  // The declared surfaces (K-26) ride like envSpec: registry metadata for the
6088
6333
  // hostname-binding picker, never behavior. Not part of any admission digest.
6089
6334
  ...(manifest.surfaces ? { surfaces: manifest.surfaces } : {}),
@@ -7527,6 +7772,56 @@ export function createControlPlaneApi(options) {
7527
7772
  }
7528
7773
  return c.json(await c.var.admin.peerGrantsStatus(actor, { tenantId, scopeId }));
7529
7774
  });
7775
+ // #1720: a caller's explicit target is a tenant decision. The dashboard checks its own
7776
+ // app-management permission; this route confines the tenant and both directory endpoints.
7777
+ const peerBindingBody = z.object({ vertical: verticalSlug, targetScopeId: scopeIdSchema.nullable() }).strict();
7778
+ app.get('/tenants/:tenantId/scopes/:scopeId/peer-bindings', async (c) => {
7779
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
7780
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
7781
+ const vertical = c.req.query('vertical');
7782
+ const pin = confinedTenant(c.get('principal'));
7783
+ if (pin !== null && pin !== tenantId)
7784
+ return c.json({ error: 'forbidden' }, 403);
7785
+ const actor = c.get('actor');
7786
+ if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId)))
7787
+ return c.json({ error: 'app not found' }, 404);
7788
+ return c.json(vertical === undefined
7789
+ ? await c.var.admin.peerBindings(actor, tenantId, scopeId)
7790
+ : await c.var.admin.peerBinding(actor, tenantId, scopeId, verticalSlug.parse(vertical)) ?? null);
7791
+ });
7792
+ app.put('/tenants/:tenantId/scopes/:scopeId/peer-bindings', async (c) => {
7793
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
7794
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
7795
+ const pin = confinedTenant(c.get('principal'));
7796
+ // The target choice is the tenant's alone. Staff may inspect it through GET, but cannot
7797
+ // choose a tenant's site on its behalf.
7798
+ if (pin !== tenantId)
7799
+ return c.json({ error: 'forbidden' }, 403);
7800
+ const body = peerBindingBody.parse(await c.req.json());
7801
+ const actor = c.get('actor');
7802
+ if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId)))
7803
+ return c.json({ error: 'app not found' }, 404);
7804
+ const operationId = ulid();
7805
+ const base = { tenantId, scopeId, operationId, vertical: body.vertical, targetScopeId: body.targetScopeId };
7806
+ const done = await auditedChange({
7807
+ flow: 'peer-binding', operationId,
7808
+ run: async () => {
7809
+ try {
7810
+ return await c.var.admin.setPeerBinding(actor, tenantId, scopeId, body.vertical, body.targetScopeId);
7811
+ }
7812
+ catch (e) {
7813
+ if (errorCodeOf(e) === 'not_found')
7814
+ throw new ControlPlaneError(404, e instanceof Error ? e.message : String(e));
7815
+ throw e;
7816
+ }
7817
+ },
7818
+ refused: (e) => e instanceof ControlPlaneError && e.status < 500,
7819
+ record: (row) => c.var.admin.recordPeerBindingChange(actor, row.phase === 'applied'
7820
+ ? { ...base, phase: 'applied', changed: row.result.changed, previousScopeId: row.result.previous }
7821
+ : { ...base, ...row }),
7822
+ });
7823
+ return auditedAnswer(c, done, 'the peer binding', (result, audit) => c.json({ ...result, ...audit }));
7824
+ });
7530
7825
  // Orgs: the portal-customer grouping (§4.1). Creating one mints no permission —
7531
7826
  // members reach what the org was GRANTED, and granting stays off this surface.
7532
7827
  app.post('/tenants/:tenantId/orgs', async (c) => {
@@ -7553,9 +7848,12 @@ export function createControlPlaneApi(options) {
7553
7848
  order: c.req.query('order'),
7554
7849
  });
7555
7850
  const entries = await c.var.admin.auditLog(c.get('actor'), filter);
7851
+ // #2064: each audited change's row says the outcome its operation stands at — the latest
7852
+ // outcome row wins — beside the raw row, which is returned as written.
7853
+ const resolved = await withAuditedOutcomes(c.var.admin, c.get('actor'), entries);
7556
7854
  // The cursor IS the last entry's id (ULID order is chronological), so the
7557
7855
  // page carries its own continuation and the console never assembles one.
7558
- return c.json(pageOf(entries, filter.limit, (e) => e.id));
7856
+ return c.json(pageOf(resolved, filter.limit, (e) => e.id));
7559
7857
  });
7560
7858
  // The recorded operational failures (#559) — the console's failures view, and the
7561
7859
  // "what does this `reference = <id>` belong to" lookup. Newest first by default,
@@ -7674,6 +7972,57 @@ export function createControlPlaneApi(options) {
7674
7972
  return c.json({ error: 'unknown issue fingerprint' }, 404);
7675
7973
  return c.json(updated);
7676
7974
  });
7975
+ // -- findings (#1748): the tenant inbox, with a lifecycle and suppress rules ----
7976
+ // Staff read the fleet; a tenant credential's tenant is forced from the principal, the
7977
+ // /ops-failures posture. Builders never reach any of these (absent from BUILDER_ROUTES): a
7978
+ // finding is the tenant's, not the vertical author's.
7979
+ app.get('/findings', async (c) => {
7980
+ const filter = findingsQuery.parse({
7981
+ tenantId: confinedTenant(c.get('principal')) ?? c.req.query('tenantId'),
7982
+ status: c.req.query('status'),
7983
+ kind: c.req.query('kind'),
7984
+ limit: c.req.query('limit'),
7985
+ });
7986
+ return c.json({ entries: await c.var.admin.listFindings(c.get('actor'), filter) });
7987
+ });
7988
+ // A verdict: acknowledge, resolve or reopen. Keyed on the tenant in the path as well as the id,
7989
+ // so another tenant's finding is a 404, never a write.
7990
+ app.put('/tenants/:tenantId/findings/:id/status', async (c) => {
7991
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
7992
+ if (outsideTenant(c.get('principal'), tenantId))
7993
+ return c.json({ error: 'forbidden' }, 403);
7994
+ const { status } = findingStatusUpdate.parse(await c.req.json());
7995
+ const updated = await c.var.admin.setFindingStatus(c.get('actor'), tenantId, c.req.param('id'), status);
7996
+ if (!updated)
7997
+ return c.json({ error: 'unknown finding' }, 404);
7998
+ return c.json(updated);
7999
+ });
8000
+ app.get('/tenants/:tenantId/finding-rules', async (c) => {
8001
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
8002
+ if (outsideTenant(c.get('principal'), tenantId))
8003
+ return c.json({ error: 'forbidden' }, 403);
8004
+ const filter = findingRulesQuery.parse({ active: c.req.query('active'), limit: c.req.query('limit') });
8005
+ const entries = await c.var.admin.listFindingRules(c.get('actor'), tenantId, filter);
8006
+ return c.json({ entries });
8007
+ });
8008
+ // Suppress with a rule: a scope plus an expiry, audited. 201 with the rule and how many
8009
+ // findings it suppressed at once.
8010
+ app.post('/tenants/:tenantId/finding-rules', async (c) => {
8011
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
8012
+ if (outsideTenant(c.get('principal'), tenantId))
8013
+ return c.json({ error: 'forbidden' }, 403);
8014
+ const input = findingRuleInput.parse(await c.req.json());
8015
+ return c.json(await c.var.admin.createFindingRule(c.get('actor'), tenantId, input), 201);
8016
+ });
8017
+ app.delete('/tenants/:tenantId/finding-rules/:ruleId', async (c) => {
8018
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
8019
+ if (outsideTenant(c.get('principal'), tenantId))
8020
+ return c.json({ error: 'forbidden' }, 403);
8021
+ const revoked = await c.var.admin.revokeFindingRule(c.get('actor'), tenantId, c.req.param('ruleId'));
8022
+ if (!revoked)
8023
+ return c.json({ error: 'unknown finding rule' }, 404);
8024
+ return c.json(revoked);
8025
+ });
7677
8026
  // -- model usage (#1054): meter 3, the one D-30 could not compute -------------
7678
8027
  // The ledger the `model-usage` intents drain into. Staff-only: the tenant-facing
7679
8028
  // read is the vertical's own usage screen over its metering engine; this is the