@substrat-run/control-plane-api 0.98.1 → 0.102.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.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;AA+C5B,OAAO,KAAK,EACV,UAAU,EACV,kBAAkB,EAClB,wBAAwB,EACxB,oBAAoB,EACpB,eAAe,EAGf,eAAe,EAMhB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAmB,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAGvE,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;CAChC;AAKD,KAAK,IAAI,GAAG;IAAE,KAAK,EAAE,eAAe,CAAC;IAAC,SAAS,EAAE,SAAS,CAAA;CAAE,CAAC;AAsR7D,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,CAo/HhG"}
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,6 @@
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, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, registerVerticalInput, 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, 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';
3
+ import { attributeFailure } from './failure-attribution.js';
3
4
  import { migrationProgress, ulid } from '@substrat-run/kernel';
4
5
  import { TENANT_HEADER } from './auth.js';
5
6
  import { connectionGrantsForScope } from './vertical-client.js';
@@ -253,14 +254,46 @@ const opsFailuresQuery = z.object({
253
254
  tenantId: tenantIdSchema.optional(),
254
255
  scopeId: scopeIdSchema.optional(),
255
256
  vertical: z.string().optional(),
257
+ // The version-registry id — the release-health narrowing (#1231).
258
+ version: z.string().optional(),
256
259
  operation: z.string().optional(),
257
260
  // Exact match — the lookup a CI log's `reference = <id>` line lands on.
258
261
  reference: z.string().optional(),
262
+ // The taxonomy code — the error-shape narrowing an issues view groups by (#1233).
263
+ code: errorCode.optional(),
259
264
  since: z.string().optional(),
260
265
  until: z.string().optional(),
261
266
  // Bounded by default exactly as /admin-log, and for the same reason.
262
267
  ...listPageQuery.shape,
263
268
  });
269
+ // The issues read (#1233). No cursor by design: grouping IS the compression —
270
+ // cardinality is the number of distinct failure shapes — and `limit` bounds it.
271
+ const issuesQuery = z.object({
272
+ status: issueStatus.optional(),
273
+ operation: z.string().optional(),
274
+ code: errorCode.optional(),
275
+ limit: z.coerce.number().int().min(1).max(500).optional(),
276
+ });
277
+ // The lifecycle verdict (#1233). The fingerprint rides in the BODY, not the path:
278
+ // it embeds U+001F by construction, and a path segment would force every caller
279
+ // through percent-encoding it can get subtly wrong.
280
+ const issueStatusUpdate = z.object({
281
+ fingerprint: z.string().min(1),
282
+ status: issueStatusInput,
283
+ });
284
+ const sweepRunsQuery = z.object({
285
+ kind: sweepRunKind.optional(),
286
+ unit: z.string().optional(),
287
+ outcome: sweepRunOutcome.optional(),
288
+ tenantId: tenantIdSchema.optional(),
289
+ scopeId: scopeIdSchema.optional(),
290
+ vertical: z.string().optional(),
291
+ connectionId: z.string().optional(),
292
+ since: z.string().optional(),
293
+ until: z.string().optional(),
294
+ // Bounded by default exactly as /ops-failures, and for the same reason.
295
+ ...listPageQuery.shape,
296
+ });
264
297
  const modelUsageQuery = z.object({
265
298
  tenantId: tenantIdSchema.optional(),
266
299
  scopeId: scopeIdSchema.optional(),
@@ -355,6 +388,10 @@ export function createControlPlaneApi(options) {
355
388
  // The declared permission registry of one version (D-39, #336) — owner-narrowed in the
356
389
  // handler like the versions list; the builder-facing Permissions tab reads it.
357
390
  { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/registry$/ },
391
+ // The emitted entity model of one version (#1214) — owner-narrowed the same way;
392
+ // the dashboard's Model tab reads it.
393
+ { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/model$/ },
394
+ { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/schedules$/ },
358
395
  { method: 'GET', re: /\/verticals\/[^/]+\/channels$/ },
359
396
  { method: 'GET', re: /\/verticals\/[^/]+\/channels\/[^/]+\/history$/ },
360
397
  { method: 'POST', re: /\/verticals\/[^/]+\/channels\/[^/]+\/promote$/ },
@@ -399,6 +436,14 @@ export function createControlPlaneApi(options) {
399
436
  // preview / provision fail. Tenant-narrowed in the handler (the forced-filter
400
437
  // pattern, like GET /scopes); the allowlist alone is not authz.
401
438
  { method: 'GET', re: /\/ops-failures$/ },
439
+ // The service→(vertical, version) join (#1231): what MY deployed scripts mean in
440
+ // signal dimensions. Tenant-narrowed in the handler (the same forced-filter
441
+ // pattern); the allowlist alone is not authz.
442
+ { method: 'GET', re: /\/service-refs$/ },
443
+ // The sweep record (#1232): when were MY connections last swept, and how did it
444
+ // go. Tenant-narrowed in the handler (the forced-filter pattern); the allowlist
445
+ // alone is not authz.
446
+ { method: 'GET', re: /\/sweep-runs$/ },
402
447
  ];
403
448
  app.use('*', async (c, next) => {
404
449
  if (c.get('principal').kind === 'builder') {
@@ -412,10 +457,21 @@ export function createControlPlaneApi(options) {
412
457
  // mask the failure it is recording, so every path through this swallows its own
413
458
  // errors. The upstream reference is extracted here — one place — so a caller
414
459
  // that only has the message still lands a searchable row.
415
- const recordFailure = (entry) => {
460
+ // Not `cause?: unknown` with an undefined check: a caught value CAN be literally
461
+ // `undefined` (`throw undefined`, a bare rejection), and that throw deserves an
462
+ // `unknown` attribution, not a skipped one. Only true omission skips.
463
+ const NO_CAUSE = Symbol('no-cause');
464
+ const recordFailure = (entry, cause = NO_CAUSE) => {
465
+ // The error SHAPE rides beside the prose (#1233): attributed here — one place,
466
+ // from the throw itself — so a fingerprint groups on a column and never has to
467
+ // regex a message. A caller with no throw in hand leaves the columns null,
468
+ // which reads as "nobody classified this" rather than a guess nobody made.
469
+ const attributed = cause === NO_CAUSE ? undefined : attributeFailure(cause);
416
470
  void admin
417
471
  .recordOpsFailure({
418
472
  ...entry,
473
+ origin: entry.origin ?? attributed?.origin ?? null,
474
+ code: entry.code ?? attributed?.code ?? null,
419
475
  reference: entry.reference ?? UPSTREAM_REFERENCE.exec(entry.message)?.[1] ?? null,
420
476
  })
421
477
  .catch(() => undefined);
@@ -474,7 +530,7 @@ export function createControlPlaneApi(options) {
474
530
  scopeId: c.req.param('scopeId') ?? null,
475
531
  status,
476
532
  message: err instanceof Error ? err.message : String(err),
477
- });
533
+ }, err);
478
534
  }
479
535
  // A 500 is, by definition, a throw whose message `mapError` did not recognise — so the
480
536
  // client gets a GENERIC body that discloses nothing, and until now nothing recorded WHAT
@@ -1350,9 +1406,10 @@ export function createControlPlaneApi(options) {
1350
1406
  tenantId,
1351
1407
  scopeId,
1352
1408
  vertical: scope.vertical,
1409
+ version: scope.verticalVersionId ?? null,
1353
1410
  status: e instanceof ControlPlaneError ? e.status : null,
1354
1411
  message,
1355
- });
1412
+ }, e);
1356
1413
  storeErrors.push(message);
1357
1414
  return undefined;
1358
1415
  }
@@ -2490,7 +2547,7 @@ export function createControlPlaneApi(options) {
2490
2547
  tenantId: input.tenantId,
2491
2548
  status: e.status,
2492
2549
  message: e.message,
2493
- });
2550
+ }, e);
2494
2551
  }
2495
2552
  return c.json({ error: e.message }, e.status);
2496
2553
  }
@@ -2562,6 +2619,68 @@ export function createControlPlaneApi(options) {
2562
2619
  const visible = p.kind === 'builder' ? all.filter((v) => v.ownerTenant === p.tenantId) : all;
2563
2620
  return c.json(pageSlice(visible, page, (v) => v.slug));
2564
2621
  });
2622
+ // -- the service→dimensions resolver (#1231's last item) -----------------------
2623
+ // What a Cloudflare service ref MEANS: each owned vertical's serving script and
2624
+ // per-version archive scripts, mapped to the signals stamp {vertical, version}
2625
+ // plus the human label beside it. A DIRECTORY read — deliberately not behind the
2626
+ // `options.observability` 501 guard, because the join must answer (for the
2627
+ // release-health and issues views) whether or not a telemetry backend is
2628
+ // configured. The dashboard's ownedServiceRefs() fetches this instead of
2629
+ // re-deriving it, so every consumer means the same thing by `version`.
2630
+ app.get('/service-refs', async (c) => {
2631
+ const p = c.get('principal');
2632
+ // The /ops-failures forced-filter pattern: a builder's tenant comes from the
2633
+ // principal, never the query. A staff/service caller must SAY whose view it
2634
+ // wants — answering fleet-wide on a forgotten param would leak, so refuse.
2635
+ const tenantId = p.kind === 'builder' ? p.tenantId : c.req.query('tenantId');
2636
+ if (!tenantId)
2637
+ throw new ControlPlaneError(400, 'tenantId is required');
2638
+ const query = z
2639
+ .object({ tenantId: tenantIdSchema, vertical: z.string().optional() })
2640
+ .parse({ tenantId, vertical: c.req.query('vertical') });
2641
+ const actor = c.get('actor');
2642
+ const all = (await admin.listVerticals(actor)).filter((v) => v.ownerTenant === query.tenantId);
2643
+ let considered = all;
2644
+ if (query.vertical !== undefined) {
2645
+ const slug = await resolveVerticalId(c, query.vertical);
2646
+ considered = all.filter((v) => v.slug === slug);
2647
+ // Non-ownership reads as absence (K-3), exactly like the per-slug registry reads.
2648
+ if (considered.length === 0)
2649
+ throw new ControlPlaneError(404, 'not found');
2650
+ }
2651
+ // Cap the per-vertical fan-out and REPORT the cut — truncating silently is the
2652
+ // defect the egress route names (#859), and the same posture holds here.
2653
+ const MAX_VERTICALS = 50;
2654
+ const verticalsTruncated = considered.length > MAX_VERTICALS;
2655
+ const entries = [];
2656
+ for (const v of considered.slice(0, MAX_VERTICALS)) {
2657
+ const versions = await admin.listVersions(actor, v.slug);
2658
+ const labelOf = new Map(versions.map((ver) => [ver.id, ver.version]));
2659
+ // The stable serving script — where real traffic lands. Its version is the
2660
+ // registry row's own servingVersionId: authoritative, not the scope-derived
2661
+ // approximation the dashboard used to make.
2662
+ if (v.servingRef && v.servingVersionId) {
2663
+ entries.push(serviceDimensions.parse({
2664
+ service: v.servingRef,
2665
+ role: 'serving',
2666
+ stamp: { vertical: v.slug, version: v.servingVersionId },
2667
+ versionLabel: labelOf.get(v.servingVersionId) ?? null,
2668
+ }));
2669
+ }
2670
+ // Each per-version archive script IS its version (previews serve these).
2671
+ for (const ver of versions) {
2672
+ if (!ver.deploymentRef)
2673
+ continue;
2674
+ entries.push(serviceDimensions.parse({
2675
+ service: ver.deploymentRef,
2676
+ role: 'archive',
2677
+ stamp: { vertical: v.slug, version: ver.id },
2678
+ versionLabel: ver.version,
2679
+ }));
2680
+ }
2681
+ }
2682
+ return c.json({ entries, verticalsTruncated });
2683
+ });
2565
2684
  app.post('/verticals', async (c) => {
2566
2685
  const input = registerVerticalInput.parse(await c.req.json());
2567
2686
  const p = c.get('principal');
@@ -2628,6 +2747,35 @@ export function createControlPlaneApi(options) {
2628
2747
  const registry = json ? (storedDeployManifest.parse(JSON.parse(json)).registry ?? null) : null;
2629
2748
  return c.json({ registry });
2630
2749
  });
2750
+ // The emitted entity model (#1214) one version ships: entities, field schemas, parent
2751
+ // edges, declared lifecycles — read out of the same retained manifest as the registry
2752
+ // above, and owner-narrowed the same way. `model` is null for a version pushed by a
2753
+ // pre-#1214 CLI or by a vertical with no model.json — the dashboard's Model tab renders
2754
+ // an empty state for both, and distinguishing them buys the tenant nothing.
2755
+ // #1232: the declared schedules of ONE version — what the dashboard's schedule-health
2756
+ // view joins the sweep record against. Null for a version pushed before the field.
2757
+ app.get('/verticals/:slug/versions/:id/schedules', async (c) => {
2758
+ const p = c.get('principal');
2759
+ const slug = await resolveVerticalId(c, c.req.param('slug'));
2760
+ if (p.kind === 'builder' && (await ownerOf(p.actor, slug)) !== p.tenantId) {
2761
+ return c.json({ error: 'not found' }, 404);
2762
+ }
2763
+ const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
2764
+ const parsed = json ? storedDeployManifest.parse(JSON.parse(json)) : null;
2765
+ // #1232: freshness rides the same read — the manifest is already in hand, and a
2766
+ // sibling route would cost a second full parse for a field sitting beside this one.
2767
+ return c.json({ schedules: parsed?.schedules ?? null, freshness: parsed?.freshness ?? null });
2768
+ });
2769
+ app.get('/verticals/:slug/versions/:id/model', async (c) => {
2770
+ const p = c.get('principal');
2771
+ const slug = await resolveVerticalId(c, c.req.param('slug'));
2772
+ if (p.kind === 'builder' && (await ownerOf(p.actor, slug)) !== p.tenantId) {
2773
+ return c.json({ error: 'not found' }, 404);
2774
+ }
2775
+ const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
2776
+ const model = json ? (storedDeployManifest.parse(JSON.parse(json)).model ?? null) : null;
2777
+ return c.json({ model });
2778
+ });
2631
2779
  // The static files (#340) one version ships: path, size, content type, content address —
2632
2780
  // read straight out of the retained manifest, which is where they were persisted for the
2633
2781
  // promote path anyway. Owner-narrowed exactly like the registry route above. `assets` is
@@ -2792,6 +2940,11 @@ export function createControlPlaneApi(options) {
2792
2940
  modules,
2793
2941
  doClasses: manifest.doClasses,
2794
2942
  bindings: [...manifest.bindings, ...storeBindings],
2943
+ // #1242: the version now being served, injected as `SUBSTRAT_VERSION_ID` so the
2944
+ // scope host stamps the signals `version` dimension. The serving script is one
2945
+ // stable script updated in place, so this refresh is what keeps the binding
2946
+ // naming the code that actually runs.
2947
+ versionId,
2795
2948
  // #1054: the model runtime is bound only for a version that ASKED for it, so the
2796
2949
  // capability is visible in the manifest diff at admit rather than fleet-wide.
2797
2950
  ...(manifest.usesModels ? { usesModels: true } : {}),
@@ -2883,7 +3036,7 @@ export function createControlPlaneApi(options) {
2883
3036
  catch (e) {
2884
3037
  const message = e instanceof Error ? e.message : String(e);
2885
3038
  console.error('stores.backfill.failed', { slug, detail: message });
2886
- recordFailure({ actor, operation: 'promote.store-backfill', vertical: slug, message });
3039
+ recordFailure({ actor, operation: 'promote.store-backfill', vertical: slug, message }, e);
2887
3040
  return { minted: [], error: message };
2888
3041
  }
2889
3042
  };
@@ -3178,6 +3331,9 @@ export function createControlPlaneApi(options) {
3178
3331
  modules,
3179
3332
  doClasses: manifest.doClasses,
3180
3333
  bindings: manifest.bindings,
3334
+ // #1242: the freshly minted version id — this archive script IS that version,
3335
+ // and a preview routed at it stamps the signals `version` dimension honestly.
3336
+ versionId: id,
3181
3337
  // #340: the verified bytes go up with the bundle. The manifest's routing config
3182
3338
  // rides along untouched — it decides what the RUNTIME does with paths, and carries
3183
3339
  // no reach, so there is nothing in it for the sandbox contract to refuse.
@@ -3210,7 +3366,7 @@ export function createControlPlaneApi(options) {
3210
3366
  tenantId: ownerTenant ?? null,
3211
3367
  status: rejected ? 422 : 502,
3212
3368
  message: detail,
3213
- });
3369
+ }, e);
3214
3370
  return rejected
3215
3371
  ? c.json({ error: 'deploy rejected', detail }, 422)
3216
3372
  : c.json({ error: 'deploy upload failed', detail }, 502);
@@ -3801,9 +3957,10 @@ export function createControlPlaneApi(options) {
3801
3957
  tenantId,
3802
3958
  scopeId: previewId,
3803
3959
  vertical: slug,
3960
+ version: opts.versionId,
3804
3961
  status: e.status,
3805
3962
  message: e.message,
3806
- });
3963
+ }, e);
3807
3964
  }
3808
3965
  throw e;
3809
3966
  }
@@ -4021,7 +4178,9 @@ export function createControlPlaneApi(options) {
4021
4178
  tenantId: p.kind === 'builder' ? p.tenantId : c.req.query('tenantId'),
4022
4179
  scopeId: c.req.query('scopeId'),
4023
4180
  vertical: c.req.query('vertical'),
4181
+ version: c.req.query('version'),
4024
4182
  operation: c.req.query('operation'),
4183
+ code: c.req.query('code'),
4025
4184
  reference: c.req.query('reference'),
4026
4185
  since: c.req.query('since'),
4027
4186
  until: c.req.query('until'),
@@ -4032,6 +4191,54 @@ export function createControlPlaneApi(options) {
4032
4191
  const entries = await admin.listOpsFailures(c.get('actor'), filter);
4033
4192
  return c.json(pageOf(entries, filter.limit, (e) => e.id));
4034
4193
  });
4194
+ // -- sweep runs (#1232): the durable record of what a sweep pass touched -------
4195
+ // Staff read fleet-wide (the console's later view); a builder's tenant is forced
4196
+ // from the principal, the /ops-failures posture exactly.
4197
+ app.get('/sweep-runs', async (c) => {
4198
+ const p = c.get('principal');
4199
+ const filter = sweepRunsQuery.parse({
4200
+ kind: c.req.query('kind'),
4201
+ unit: c.req.query('unit'),
4202
+ outcome: c.req.query('outcome'),
4203
+ tenantId: p.kind === 'builder' ? p.tenantId : c.req.query('tenantId'),
4204
+ scopeId: c.req.query('scopeId'),
4205
+ vertical: c.req.query('vertical'),
4206
+ connectionId: c.req.query('connectionId'),
4207
+ since: c.req.query('since'),
4208
+ until: c.req.query('until'),
4209
+ limit: c.req.query('limit'),
4210
+ cursor: c.req.query('cursor'),
4211
+ order: c.req.query('order'),
4212
+ });
4213
+ const entries = await admin.listSweepRuns(c.get('actor'), filter);
4214
+ return c.json(pageOf(entries, filter.limit, (e) => e.id));
4215
+ });
4216
+ // -- issues (#1233): failures grouped by fingerprint, with a lifecycle --------
4217
+ // Staff-only (absent from BUILDER_ROUTES): an issue is a fleet-scoped aggregate
4218
+ // with no tenant column, so the forced-filter posture cannot narrow it — a
4219
+ // builder's per-app view derives from their tenant-forced /ops-failures instead.
4220
+ app.get('/issues', async (c) => {
4221
+ const filter = issuesQuery.parse({
4222
+ status: c.req.query('status'),
4223
+ operation: c.req.query('operation'),
4224
+ code: c.req.query('code'),
4225
+ limit: c.req.query('limit'),
4226
+ });
4227
+ const entries = await admin.listIssues(c.get('actor'), filter);
4228
+ // No cursor, deliberately — see issuesQuery. `entries` alone, so a client
4229
+ // never walks a continuation that cannot exist.
4230
+ return c.json({ entries });
4231
+ });
4232
+ // The staff verdict on one issue: resolve, ignore, or reopen. `regressed` is
4233
+ // ingest's word and the input schema refuses it. 404 for an unknown fingerprint
4234
+ // rather than an invented row.
4235
+ app.put('/issues/status', async (c) => {
4236
+ const input = issueStatusUpdate.parse(await c.req.json());
4237
+ const updated = await admin.setIssueStatus(c.get('actor'), input.fingerprint, input.status);
4238
+ if (!updated)
4239
+ return c.json({ error: 'unknown issue fingerprint' }, 404);
4240
+ return c.json(updated);
4241
+ });
4035
4242
  // -- model usage (#1054): meter 3, the one D-30 could not compute -------------
4036
4243
  // The ledger the `model-usage` intents drain into. Staff-only: the tenant-facing
4037
4244
  // read is the vertical's own usage screen over its metering engine; this is the