@substrat-run/control-plane-api 0.110.0 → 0.112.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
@@ -6,7 +6,7 @@ import type { PlatformActorAuth, BuilderAuth, Principal } from './auth.js';
6
6
  import { type VerticalClient } from './vertical-client.js';
7
7
  import type { DeployVerticalFn, FetchVerticalAssetFn, FetchVerticalModulesFn } from './deploy.js';
8
8
  import type { PatchScriptBindingsFn } from './wfp.js';
9
- import type { ObservabilityReader } from './observability.js';
9
+ import { type ObservabilityReader } from './observability.js';
10
10
  import type { PlatformRuntime } from './platform-runtime.js';
11
11
  import { type DoNamespaceReader } from './do-namespaces.js';
12
12
  import type { DirectoryBackupStore, ScopeBackupStore } from './backups.js';
@@ -308,6 +308,10 @@ export declare const denialLogQuery: z.ZodObject<{
308
308
  since: z.ZodOptional<z.ZodString>;
309
309
  until: z.ZodOptional<z.ZodString>;
310
310
  limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
311
+ groupBy: z.ZodOptional<z.ZodEnum<{
312
+ "actor-permission": "actor-permission";
313
+ operation: "operation";
314
+ }>>;
311
315
  }, z.core.$strip>;
312
316
  export declare const DEFAULT_MODEL_MARGIN_PERCENT = 20;
313
317
  /**
package/dist/api.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,OAAO,EAmDL,CAAC,EAGF,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EACV,UAAU,EACV,kBAAkB,EAClB,wBAAwB,EACxB,oBAAoB,EACpB,eAAe,EAGf,eAAe,EAOhB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAmB,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAIvE,OAAO,KAAK,EAAE,iBAAiB,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3E,OAAO,EAA4B,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAmBrF,OAAO,KAAK,EAEV,gBAAgB,EAChB,oBAAoB,EACpB,sBAAsB,EACvB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AAyBtD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAC9D,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;AAEtB,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;;;;;;;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;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;;;;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;AA0O7D;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,cAAc;;;;;;;iBAOzB,CAAC;AA+FH,eAAO,MAAM,4BAA4B,KAAK,CAAC;AAM/C;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,GAAG,IAAI,CAAC;IAAE,SAAS,EAAE,IAAI,CAAA;CAAE,CAAC,CAm7IhG"}
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
package/dist/api.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Hono } from 'hono';
2
- import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ASSET_PART_PREFIX, assetHash, channelName, connectionActivity, connectionActivitySource, connectionCredential, connectionFilter, 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, createOrgInput, roleKey as roleKeySchema, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, registerVerticalInput, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, } from '@substrat-run/contracts';
2
+ import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ASSET_PART_PREFIX, assetHash, channelName, connectionActivity, connectionActivitySource, connectionCredential, connectionFilter, 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, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, 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, } from '@substrat-run/contracts';
3
3
  import { attributeFailure } from './failure-attribution.js';
4
4
  import { migrationProgress, ulid } from '@substrat-run/kernel';
5
5
  import { TENANT_HEADER } from './auth.js';
@@ -28,6 +28,7 @@ function problem(c, answer) {
28
28
  }
29
29
  import { backfillDeclaredStores, blobStoreBindings, collectBlobStoreHandles, collectTenantStoreHandles, missingStoresForTenant, tenantStoreBindings, } from './tenant-stores.js';
30
30
  import { mintPushToken, pushActorFor } from './push-token.js';
31
+ import { TENANT_SERIES_SCOPE_CAP } from './observability.js';
31
32
  import { namespacesForScript } from './do-namespaces.js';
32
33
  import { backupDirectoryIfDue } from './directory-backup.js';
33
34
  import { isCustomHostname, validateBindableHostname, } from './custom-hostnames.js';
@@ -263,6 +264,8 @@ export const denialLogQuery = z.object({
263
264
  since: z.string().optional(),
264
265
  until: z.string().optional(),
265
266
  limit: z.coerce.number().int().min(1).max(DENIAL_LIMIT_MAX).default(DEFAULT_DENIAL_LIMIT),
267
+ // Which bucketing the summary answers with (#1456); the row read ignores it.
268
+ groupBy: denialGroupBy.optional(),
266
269
  });
267
270
  const opsFailuresQuery = z.object({
268
271
  tenantId: tenantIdSchema.optional(),
@@ -504,6 +507,7 @@ export function createControlPlaneApi(options) {
504
507
  // there is no fleet-wide answer for a forgotten filter to fall back to. Narrowed in
505
508
  // the handler by the forced-filter pattern; the allowlist alone is not authz.
506
509
  { method: 'GET', re: /\/observability\/tenant-metrics$/ },
510
+ { method: 'GET', re: /\/observability\/tenant-metrics-series$/ },
507
511
  { method: 'GET', re: /\/observability\/tenant-logs$/ },
508
512
  ];
509
513
  app.use('*', async (c, next) => {
@@ -1279,10 +1283,10 @@ export function createControlPlaneApi(options) {
1279
1283
  const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1280
1284
  if (!scope)
1281
1285
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1282
- const vertical = await verticalForScope(c, scope);
1283
- return c.json(vertical
1284
- ? await vertical.listScopeTables(scopeId)
1285
- : await admin.listScopeTables(c.get('actor'), tenantId, scopeId));
1286
+ return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'listScopeTables', null, {
1287
+ viaVertical: (v) => v.listScopeTables(scopeId),
1288
+ colocated: () => admin.listScopeTables(c.get('actor'), tenantId, scopeId),
1289
+ }, (r) => r.length));
1286
1290
  });
1287
1291
  // Scope health (#321, criterion #3). The silent failure the field report chased was an
1288
1292
  // ACTIVE scope serving traffic from a DO whose `_substrat_roles` projection is EMPTY:
@@ -1313,10 +1317,12 @@ export function createControlPlaneApi(options) {
1313
1317
  const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1314
1318
  if (!scope)
1315
1319
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1316
- const vertical = await verticalForScope(c, scope);
1317
- const tables = vertical
1318
- ? await vertical.listScopeTables(scopeId)
1319
- : await admin.listScopeTables(c.get('actor'), tenantId, scopeId);
1320
+ // Health reads the scope the same way the Data view does, so it leaves the same
1321
+ // row: an auditor should not be able to tell which route reached the tables.
1322
+ const tables = await delegatedRead(c, tenantId, scopeId, scope, 'listScopeTables', null, {
1323
+ viaVertical: (v) => v.listScopeTables(scopeId),
1324
+ colocated: () => admin.listScopeTables(c.get('actor'), tenantId, scopeId),
1325
+ }, (r) => r.length);
1320
1326
  const roles = tables.find((t) => t.name === '_substrat_roles');
1321
1327
  const roleCount = roles ? roles.rowCount : null;
1322
1328
  const roleProjectionEmpty = scope.status === 'active' && roleCount === 0;
@@ -1551,10 +1557,91 @@ export function createControlPlaneApi(options) {
1551
1557
  const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1552
1558
  if (!scope)
1553
1559
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1560
+ return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'facetEvents', input, {
1561
+ viaVertical: (v) => v.facetEvents(scopeId, input),
1562
+ colocated: () => admin.facetEvents(c.get('actor'), tenantId, scopeId, input),
1563
+ },
1564
+ // The buckets RETURNED, not `total`: the row says how much this read handed
1565
+ // back, and a capped facet hands back fewer than it counted.
1566
+ (r) => r.buckets.length));
1567
+ });
1568
+ /**
1569
+ * Serve a scope read from whichever side holds the data, and leave ONE K-24 row
1570
+ * either way (#1357).
1571
+ *
1572
+ * Every scope-addressed read runs the same ladder — resolve the scope record, ask the
1573
+ * vertical when one resolves, the co-located host otherwise — and only the second
1574
+ * branch reaches `HostAdmin`, where `recordAccess` lives. So on the production path,
1575
+ * which is every hosted vertical, the access log held a `getScopeRecord` entry and
1576
+ * nothing saying what was read: a summary page, a table walk and one record's history
1577
+ * were indistinguishable, and the history page is the one carrying payloads.
1578
+ *
1579
+ * `count` exists because the row records how much came back and every answer counts
1580
+ * differently — a page has `entries`, a facet has `buckets`, a chain has its own
1581
+ * length. Handing each site its own counter keeps that honest rather than logging a
1582
+ * zero everywhere the shape is not a list.
1583
+ *
1584
+ * The record is written AFTER the read succeeds and is not caught: a failed write
1585
+ * fails the request. That is the co-located branch's existing trade, where
1586
+ * `recordAccess` is awaited inside the read — and returning rows whose disclosure went
1587
+ * unrecorded is the thing K-24 exists to prevent.
1588
+ *
1589
+ * `input` is the ROUTE's input, and what the row carries is `delegatedReadParams`'s
1590
+ * projection of it — never the input itself. The co-located branch logs a chosen
1591
+ * subset per method (the entity, not the page; the event, not the walk's depth), and
1592
+ * a row that carried the whole input on this branch alone would tell an auditor which
1593
+ * branch served the request. The projection lives in contracts so it is one
1594
+ * definition; the test drives every method both ways and compares the rows.
1595
+ */
1596
+ async function delegatedRead(c, tenantId, scopeId, scope, method, input, run, count) {
1554
1597
  const vertical = await verticalForScope(c, scope);
1555
- return c.json(vertical
1556
- ? await vertical.facetEvents(scopeId, input)
1557
- : await admin.facetEvents(c.get('actor'), tenantId, scopeId, input));
1598
+ // The co-located branch already records inside the read — adding a row here would
1599
+ // log it twice, which is a different kind of dishonest log.
1600
+ if (!vertical)
1601
+ return run.colocated();
1602
+ const answer = await run.viaVertical(vertical);
1603
+ await admin.recordDelegatedRead(c.get('actor'), {
1604
+ method,
1605
+ tenantId,
1606
+ scopeId,
1607
+ params: delegatedReadParams[method](input),
1608
+ resultCount: count(answer),
1609
+ });
1610
+ return answer;
1611
+ }
1612
+ // #1237: one event's causal chain, walked backwards. Delegated exactly like the
1613
+ // history read below — through the vertical holding the scope's data when one
1614
+ // resolves, the co-located host otherwise.
1615
+ app.get('/tenants/:tenantId/scopes/:scopeId/cause', async (c) => {
1616
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1617
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
1618
+ const input = eventCauseInput.parse({
1619
+ eventId: c.req.query('eventId'),
1620
+ maxDepth: c.req.query('maxDepth') ? Number(c.req.query('maxDepth')) : undefined,
1621
+ });
1622
+ const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1623
+ if (!scope)
1624
+ return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1625
+ return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'eventCause', input, {
1626
+ viaVertical: (v) => v.eventCause(scopeId, input),
1627
+ colocated: () => admin.eventCause(c.get('actor'), tenantId, scopeId, input),
1628
+ }, (r) => r.chain.length));
1629
+ });
1630
+ // #1237 forward: what one event set off. Delegated like its backwards twin.
1631
+ app.get('/tenants/:tenantId/scopes/:scopeId/effects', async (c) => {
1632
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1633
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
1634
+ const input = eventEffectsInput.parse({
1635
+ eventId: c.req.query('eventId'),
1636
+ maxNodes: c.req.query('maxNodes') ? Number(c.req.query('maxNodes')) : undefined,
1637
+ });
1638
+ const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1639
+ if (!scope)
1640
+ return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1641
+ return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'eventEffects', input, {
1642
+ viaVertical: (v) => v.eventEffects(scopeId, input),
1643
+ colocated: () => admin.eventEffects(c.get('actor'), tenantId, scopeId, input),
1644
+ }, (r) => r.count));
1558
1645
  });
1559
1646
  // #1235: one record's event history — the per-entity pivot of the audit spine.
1560
1647
  // Delegated exactly like the table reads: through the vertical that holds the
@@ -1571,10 +1658,10 @@ export function createControlPlaneApi(options) {
1571
1658
  const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1572
1659
  if (!scope)
1573
1660
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1574
- const vertical = await verticalForScope(c, scope);
1575
- return c.json(vertical
1576
- ? await vertical.entityHistory(scopeId, input)
1577
- : await admin.entityHistory(c.get('actor'), tenantId, scopeId, input));
1661
+ return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'entityHistory', input, {
1662
+ viaVertical: (v) => v.entityHistory(scopeId, input),
1663
+ colocated: () => admin.entityHistory(c.get('actor'), tenantId, scopeId, input),
1664
+ }, (r) => r.entries.length));
1578
1665
  });
1579
1666
  app.get('/tenants/:tenantId/scopes/:scopeId/tables/:table', async (c) => {
1580
1667
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
@@ -1587,10 +1674,10 @@ export function createControlPlaneApi(options) {
1587
1674
  const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1588
1675
  if (!scope)
1589
1676
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1590
- const vertical = await verticalForScope(c, scope);
1591
- return c.json(vertical
1592
- ? await vertical.readScopeTable(scopeId, input)
1593
- : await admin.readScopeTable(c.get('actor'), tenantId, scopeId, input));
1677
+ return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'readScopeTable', input, {
1678
+ viaVertical: (v) => v.readScopeTable(scopeId, input),
1679
+ colocated: () => admin.readScopeTable(c.get('actor'), tenantId, scopeId, input),
1680
+ }, (r) => r.rows.length));
1594
1681
  });
1595
1682
  // The SQL console (#219): one read-only statement, POSTed because SQL does not
1596
1683
  // belong in a URL. Same delegation as the table reads; the gate's refusal maps to
@@ -1604,10 +1691,14 @@ export function createControlPlaneApi(options) {
1604
1691
  const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1605
1692
  if (!scope)
1606
1693
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1607
- const vertical = await verticalForScope(c, scope);
1608
- return c.json(vertical
1609
- ? await vertical.queryScope(scopeId, input)
1610
- : await admin.queryScope(c.get('actor'), tenantId, scopeId, input));
1694
+ return c.json(await delegatedRead(
1695
+ // The SQL is the argument the co-located branch logs, so it is the argument
1696
+ // here — an audit row for the read-only console that did not carry the
1697
+ // statement would answer "someone queried" and nothing a reader needs.
1698
+ c, tenantId, scopeId, scope, 'queryScope', input, {
1699
+ viaVertical: (v) => v.queryScope(scopeId, input),
1700
+ colocated: () => admin.queryScope(c.get('actor'), tenantId, scopeId, input),
1701
+ }, (r) => r.rows.length));
1611
1702
  });
1612
1703
  // The K-35 denial log (#867) — the third of the platform's three logs and the last to
1613
1704
  // get a reader. `/admin-log` holds staff mutations and the K-24 access log staff
@@ -1631,10 +1722,10 @@ export function createControlPlaneApi(options) {
1631
1722
  const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1632
1723
  if (!scope)
1633
1724
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1634
- const vertical = await verticalForScope(c, scope);
1635
- return c.json(vertical
1636
- ? await vertical.listDenials(scopeId, filter)
1637
- : await admin.listDenials(c.get('actor'), tenantId, scopeId, filter));
1725
+ return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'listDenials', filter, {
1726
+ viaVertical: (v) => v.listDenials(scopeId, filter),
1727
+ colocated: () => admin.listDenials(c.get('actor'), tenantId, scopeId, filter),
1728
+ }, (r) => r.length));
1638
1729
  });
1639
1730
  app.get('/tenants/:tenantId/scopes/:scopeId/denials/summary', async (c) => {
1640
1731
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
@@ -1647,10 +1738,10 @@ export function createControlPlaneApi(options) {
1647
1738
  const scope = await admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
1648
1739
  if (!scope)
1649
1740
  return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
1650
- const vertical = await verticalForScope(c, scope);
1651
- return c.json(vertical
1652
- ? await vertical.summarizeDenials(scopeId, filter)
1653
- : await admin.summarizeDenials(c.get('actor'), tenantId, scopeId, filter));
1741
+ return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'summarizeDenials', filter, {
1742
+ viaVertical: (v) => v.summarizeDenials(scopeId, filter),
1743
+ colocated: () => admin.summarizeDenials(c.get('actor'), tenantId, scopeId, filter),
1744
+ }, (r) => r.buckets.length));
1654
1745
  });
1655
1746
  // The owner seat (#925). Both reads go to the VERTICAL — the seat lives in its identity
1656
1747
  // directory, in its deployment — after the same K-3 cross-check every scope-addressed read
@@ -3707,6 +3798,31 @@ export function createControlPlaneApi(options) {
3707
3798
  });
3708
3799
  return c.json(await options.observability.tenantMetrics(input));
3709
3800
  });
3801
+ // The bucketed twin (#1447): the series a team-level chart plots, one line per app.
3802
+ // Same grain, same forced tenant, same 501 when the reader cannot bucket — an empty
3803
+ // series would render as "quiet". `scopeId` repeats, like `service` on the script-grain
3804
+ // series, so "all my apps" is one read; it is capped because the answer is scopes ×
3805
+ // buckets, and at least one is required because an unnarrowed ask is not on offer.
3806
+ app.get('/observability/tenant-metrics-series', async (c) => {
3807
+ if (!options.observability?.tenantMetricsSeries) {
3808
+ return c.json({ error: 'bucketed tenant-grain observability is not configured on this control plane' }, 501);
3809
+ }
3810
+ const p = c.get('principal');
3811
+ const tenantId = p.kind === 'builder' ? p.tenantId : c.req.query('tenantId');
3812
+ if (!tenantId)
3813
+ throw new ControlPlaneError(400, 'tenantId is required');
3814
+ const scopeIds = (c.req.queries('scopeId') ?? []).filter((s) => s.length > 0);
3815
+ const input = z
3816
+ .object({
3817
+ tenantId: tenantIdSchema,
3818
+ // Narrowing WITHIN the tenant, as on /tenant-metrics: a foreign scope id yields
3819
+ // zero rows rather than somebody else's, so no ownership check is needed here.
3820
+ scopeIds: z.array(z.string().min(1).max(64)).min(1).max(TENANT_SERIES_SCOPE_CAP),
3821
+ hours: z.coerce.number().int().min(1).max(72).default(24),
3822
+ })
3823
+ .parse({ tenantId, scopeIds, hours: c.req.query('hours') });
3824
+ return c.json(await options.observability.tenantMetricsSeries(input));
3825
+ });
3710
3826
  app.get('/observability/tenant-logs', async (c) => {
3711
3827
  if (!options.observability?.tenantLogs) {
3712
3828
  return c.json({ error: 'tenant-grain observability is not configured on this control plane' }, 501);
@@ -3723,7 +3839,35 @@ export function createControlPlaneApi(options) {
3723
3839
  level: z.enum(['log', 'info', 'warn', 'error', 'debug']).optional(),
3724
3840
  search: z.string().min(1).max(200).optional(),
3725
3841
  hours: z.coerce.number().int().min(1).max(72).default(24),
3842
+ // The chart's time cursor (#1447): a window that ENDS in the past, which `hours`
3843
+ // cannot spell — it always ends at now.
3844
+ since: z.string().datetime({ offset: true }).optional(),
3845
+ until: z.string().datetime({ offset: true }).optional(),
3726
3846
  limit: z.coerce.number().int().min(1).max(200).default(100),
3847
+ })
3848
+ .superRefine((v, ctx) => {
3849
+ const now = Date.now();
3850
+ const to = v.until ? Date.parse(v.until) : now;
3851
+ const from = v.since ? Date.parse(v.since) : to - v.hours * 3_600_000;
3852
+ // On the resolved bounds rather than on the pair, so a lone `since` in the future
3853
+ // is refused by the same line: with no `until` it is measured against now, and
3854
+ // without this it would reach the backend as a window that cannot contain
3855
+ // anything and come back as an empty page nobody could explain.
3856
+ if (from >= to) {
3857
+ ctx.addIssue({ code: 'custom', path: ['since'], message: 'since must be before until' });
3858
+ }
3859
+ // The SAME ceiling `hours` carries, applied to the window however it was spelled.
3860
+ // Otherwise the pair is a second door onto a read the first one caps at three
3861
+ // days, and the two spellings together would read past what either allows.
3862
+ if (to - from > 72 * 3_600_000) {
3863
+ ctx.addIssue({ code: 'custom', path: ['since'], message: 'the window may not exceed 72 hours' });
3864
+ }
3865
+ // A window ending in the future returns nothing and says nothing about why, which
3866
+ // a reader takes for "my app was quiet". Five minutes of slack, because the
3867
+ // instant is stamped by a browser's clock rather than by ours.
3868
+ if (to > now + 5 * 60_000) {
3869
+ ctx.addIssue({ code: 'custom', path: ['until'], message: 'until may not be more than 5 minutes in the future' });
3870
+ }
3727
3871
  })
3728
3872
  .parse({
3729
3873
  tenantId,
@@ -3732,6 +3876,8 @@ export function createControlPlaneApi(options) {
3732
3876
  level: c.req.query('level') || undefined,
3733
3877
  search: c.req.query('search') || undefined,
3734
3878
  hours: c.req.query('hours'),
3879
+ since: c.req.query('since') || undefined,
3880
+ until: c.req.query('until') || undefined,
3735
3881
  limit: c.req.query('limit'),
3736
3882
  });
3737
3883
  return c.json(await options.observability.tenantLogs(input));