@substrat-run/control-plane-api 0.159.0 → 0.161.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 (68) hide show
  1. package/dist/api.d.ts +33 -1
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +674 -63
  4. package/dist/api.js.map +1 -1
  5. package/dist/browser.d.ts +2 -2
  6. package/dist/browser.d.ts.map +1 -1
  7. package/dist/browser.js +2 -1
  8. package/dist/browser.js.map +1 -1
  9. package/dist/cf-observability.d.ts +30 -0
  10. package/dist/cf-observability.d.ts.map +1 -1
  11. package/dist/cf-observability.js +304 -99
  12. package/dist/cf-observability.js.map +1 -1
  13. package/dist/custom-hostnames.d.ts +30 -0
  14. package/dist/custom-hostnames.d.ts.map +1 -1
  15. package/dist/custom-hostnames.js +25 -2
  16. package/dist/custom-hostnames.js.map +1 -1
  17. package/dist/deploy.d.ts +3 -0
  18. package/dist/deploy.d.ts.map +1 -1
  19. package/dist/deploy.js.map +1 -1
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js +3 -1
  22. package/dist/errors.js.map +1 -1
  23. package/dist/index.d.ts +7 -5
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +6 -4
  26. package/dist/index.js.map +1 -1
  27. package/dist/observability.d.ts +29 -2
  28. package/dist/observability.d.ts.map +1 -1
  29. package/dist/observability.js +8 -0
  30. package/dist/observability.js.map +1 -1
  31. package/dist/platform-drain.d.ts +26 -2
  32. package/dist/platform-drain.d.ts.map +1 -1
  33. package/dist/platform-drain.js +297 -119
  34. package/dist/platform-drain.js.map +1 -1
  35. package/dist/platform-entry.generated.d.ts +2 -2
  36. package/dist/platform-entry.generated.d.ts.map +1 -1
  37. package/dist/platform-entry.generated.js +5 -5
  38. package/dist/platform-entry.generated.js.map +1 -1
  39. package/dist/platform-wire.d.ts +51 -0
  40. package/dist/platform-wire.d.ts.map +1 -0
  41. package/dist/platform-wire.js +163 -0
  42. package/dist/platform-wire.js.map +1 -0
  43. package/dist/scope-copy-backfill.d.ts.map +1 -1
  44. package/dist/scope-copy-backfill.js +22 -9
  45. package/dist/scope-copy-backfill.js.map +1 -1
  46. package/dist/scope-copy-cleanup.js +1 -1
  47. package/dist/scope-copy-cleanup.js.map +1 -1
  48. package/dist/staff-client.d.ts +38 -2
  49. package/dist/staff-client.d.ts.map +1 -1
  50. package/dist/staff-client.js +24 -1
  51. package/dist/staff-client.js.map +1 -1
  52. package/dist/vertical-client.d.ts +116 -4
  53. package/dist/vertical-client.d.ts.map +1 -1
  54. package/dist/vertical-client.js +366 -25
  55. package/dist/vertical-client.js.map +1 -1
  56. package/dist/wire-settings.d.ts +77 -0
  57. package/dist/wire-settings.d.ts.map +1 -0
  58. package/dist/wire-settings.js +155 -0
  59. package/dist/wire-settings.js.map +1 -0
  60. package/dist/wire-upload.d.ts +100 -0
  61. package/dist/wire-upload.d.ts.map +1 -0
  62. package/dist/wire-upload.js +141 -0
  63. package/dist/wire-upload.js.map +1 -0
  64. package/dist/wire-walk.d.ts +140 -0
  65. package/dist/wire-walk.d.ts.map +1 -0
  66. package/dist/wire-walk.js +339 -0
  67. package/dist/wire-walk.js.map +1 -0
  68. package/package.json +10 -10
package/dist/api.js CHANGED
@@ -2,14 +2,15 @@ import { PREVIEW_CONNECTIONS_REFUSAL } from './connection-relay.js';
2
2
  import { connectionBindingOptions } from './connection-binding-options.js';
3
3
  import { resolveObservabilityWindow } from './observability-window.js';
4
4
  import { Hono } from 'hono';
5
- import { permissionKey, parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, lakeEventsInput, ownerTransferInput, memberInviteInput, memberRoleInput, ASSET_PART_PREFIX, assetHash, STORE_DRIVERS, STORE_RELEASE_MAX_BYTES, 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, FINDINGS_LIMIT_DEFAULT, FINDINGS_LIMIT_MAX, findingCursorOf, findingKind, findingRuleInput, findingStatus, findingStatusInput, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, impersonationStamp, promotionAcknowledgement, bindAcknowledgement, rebindAcknowledgement, 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, validationIssuesFrom, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, substratError, errorCodeOf, capabilityFilterQuery, capabilityId as capabilityIdSchema, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, SOCIAL_RELAY_VERTICALS, } from '@substrat-run/contracts';
5
+ import { permissionKey, parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, lakeEventsInput, ownerTransferInput, memberInviteInput, memberRoleInput, ASSET_PART_PREFIX, assetHash, STORE_DRIVERS, STORE_RELEASE_MAX_BYTES, 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, FINDINGS_LIMIT_DEFAULT, FINDINGS_LIMIT_MAX, findingCursorOf, findingKind, findingRuleInput, findingStatus, findingStatusInput, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, impersonationStamp, promotionAcknowledgement, bindAcknowledgement, rebindAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, lifecycleFlowInput, operationSeriesCount, operationSeriesInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, peerSwitch, verticalSlug, scopeText, 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, requestRollupReading, meterDaysReading, verticalOwnersReading, utcDay, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, validationIssuesFrom, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, substratError, errorCodeOf, capabilityFilterQuery, capabilityId as capabilityIdSchema, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, SOCIAL_RELAY_VERTICALS, } from '@substrat-run/contracts';
6
+ import { upstreamHandleIn, upstreamReferenceOf, withUpstreamReference } from '@substrat-run/contracts';
6
7
  import { SCOPE_COPY_LEASE_MS, copyRestoreFence, publishedMigrationDigest, replayStoreErasures } from '@substrat-run/kernel';
7
8
  import { attributeFailure } from './failure-attribution.js';
8
9
  import { auditedChange, withAuditedOutcomes } from './audited-change.js';
9
- import { BIND_EXPORT_BREAK_REFUSAL, CARRIED_AWAY_KEY, bindExportBreakRefusal, carriedAwayDump, dumpMetaValue, metaValueIn, crossVerticalHealth, exportBreakRefusal, isBindExportBreakRefusal, isExportBreakRefusal, isPrimaryScope, promoteReviewDigest, rebindReviewDigest, assertRebindReviewCurrent, migrationProgress, ulid, auditWarningOf, } from '@substrat-run/kernel';
10
+ import { BIND_EXPORT_BREAK_REFUSAL, CARRIED_AWAY_KEY, bindExportBreakRefusal, carriedAwayDump, dumpMetaValue, metaValueIn, crossVerticalHealth, exportBreakRefusal, isBindExportBreakRefusal, isExportBreakRefusal, isExportBreakUnjudged, isPrimaryScope, promoteReviewDigest, rebindReviewDigest, assertRebindReviewCurrent, migrationProgress, ulid, auditWarningOf, REQUEST_ROLLUP_LIMIT_DEFAULT, REQUEST_ROLLUP_LIMIT_MAX, METER_DAYS_LIMIT_DEFAULT, METER_DAYS_LIMIT_MAX, releaseRolesAskScopes, releaseRoleSetRefusal, withheldOpsFailure, } from '@substrat-run/kernel';
10
11
  import { TENANT_HEADER, confinedTenant } from './auth.js';
11
12
  import { mintTenantToken } from './tenant-token.js';
12
- import { beginTransfer, connectionGrantsForScope, transferScope } from './vertical-client.js';
13
+ import { beginTransfer, connectionGrantsForScope, transferScope, isScopeNotHeld, notHeldIsBenign } from './vertical-client.js';
13
14
  import { COPY_JOB_MAX_MS, copyJobAt, copyJobHeld, copyJobId, copyJobLive, copyTagId, versionIdOfOrder, versionOrderOf, } from './copy-job.js';
14
15
  import { runningVersionOf, runningVersionStoresOf, scopeStoreReachOf, scopeStoresOf, unrouteScope } from './platform-scope-stores.js';
15
16
  import { STORE_REBUILD_REQUEST_SUBREQUESTS, runStoreFleetRebuild, storeRebuildBudget, storeRebuildStatus } from './store-rebuild.js';
@@ -61,11 +62,13 @@ function refuseStatus(c, status, detail, carried) {
61
62
  }
62
63
  import { backfillDeclaredStores, blobStoreBindings, collectBlobStoreHandles, collectTenantStoreHandles, missingStoresForTenant, tenantStoreBindings, } from './tenant-stores.js';
63
64
  import { mintPushToken, pushActorFor } from './push-token.js';
64
- import { TENANT_SERIES_SCOPE_CAP, REQUEST_FACET_KEYS } from './observability.js';
65
+ import { TENANT_SERIES_SCOPE_CAP, REQUEST_FACET_KEYS, LOGS_INCOMPLETE_HEADER } from './observability.js';
65
66
  import { parseTenantRequestQuery, parseTenantWindowQuery, refineTenantWindow, scriptFamiliesOfScopes } from './tenant-request-query.js';
66
67
  import { namespacesForScript } from './do-namespaces.js';
67
68
  import { backupDirectoryIfDue } from './directory-backup.js';
68
69
  import { deployedDoClasses, platformSweeperDecision } from './platform-entry.js';
70
+ import { wireSettingsRefusal, wireUploadFor } from './wire-upload.js';
71
+ import { parseWireWalkCursor, runWireWalk, WIRE_WALK_LIMIT_DEFAULT, WIRE_WALK_LIMIT_MAX, WIRE_WALK_RELEASE_MAX, } from './wire-walk.js';
69
72
  import { STORAGE_PAGE_DEFAULT, STORAGE_PAGE_MAX, readStoragePage } from './storage-meter.js';
70
73
  import { queryLakeEvents } from './lake-gateway.js';
71
74
  import { LakeQueryError } from './r2-sql-engine.js';
@@ -166,6 +169,46 @@ function withholdIfPredatesRule(input, answer) {
166
169
  truncated: false,
167
170
  };
168
171
  }
172
+ /** #2446: the most scopes one promote reads the role state of (`roleStateScopeCap`). */
173
+ export const ROLE_STATE_SCOPE_CAP = 500;
174
+ /** #2446: how many of those reads are in flight at once. */
175
+ const ROLE_STATE_CONCURRENCY = 8;
176
+ /** #2446: what a promote answers about the scopes it could not ask for their roles. */
177
+ export const ROLE_STATE_UNREAD_NOTE = "their deployment predates the role-state read, so this version's roles were not judged against them before the " +
178
+ 'promote; their reconcile still refuses a role set it cannot define';
179
+ /**
180
+ * #2446: the promote's refusal for a role-state judgement, or null when it passes, as `pin` may
181
+ * read it. A confined caller sees only its own tenant's scopes by id and every other tenant as a
182
+ * count, and never what a deployment answered (that is logged where it was read); staff see all of
183
+ * it. A refusal found (409) is answered ahead of a read that failed (502), since it stands either way.
184
+ */
185
+ function roleStateAnswer(roles, pin) {
186
+ if (roles.refused.length > 0) {
187
+ const { visible, otherTenants } = narrowToCaller(roles.refused, pin);
188
+ const why = visible[0] ? `: ${visible[0].refusal}` : ', none of them yours';
189
+ return {
190
+ status: 409,
191
+ message: `promotion refused: this version's roles cannot be defined in ${roles.refused.length} of the scopes it would move, ` +
192
+ `so their reconcile would be refused there${why}. Nothing was promoted`,
193
+ extra: { roleRefusals: { affected: visible, ...(otherTenants ? { otherTenants } : {}) } },
194
+ };
195
+ }
196
+ if (roles.failed.length > 0) {
197
+ const { visible, otherTenants } = narrowToCaller(roles.failed, pin);
198
+ return {
199
+ status: 502,
200
+ message: `the roles of ${roles.failed.length} of the scopes this version would move could not be read, and its roles are ` +
201
+ `judged against them before it moves them. Nothing was promoted; retry the promote`,
202
+ extra: {
203
+ roleStateUnreadable: {
204
+ affected: pin === null ? visible : visible.map(({ tenantId, scopeId }) => ({ tenantId, scopeId })),
205
+ ...(otherTenants ? { otherTenants } : {}),
206
+ },
207
+ },
208
+ };
209
+ }
210
+ return null;
211
+ }
169
212
  /**
170
213
  * A fleet-wide report, narrowed to what a caller may see: a confined caller gets its own
171
214
  * tenant's rows and a COUNT of the other tenants, never their ids (K-3's forced filter, held on
@@ -190,9 +233,11 @@ const provisionScopeBody = z.object({
190
233
  tenantId: tenantIdSchema,
191
234
  scopeId: scopeIdSchema,
192
235
  slug: z.string().optional(),
193
- kind: z.string().optional(),
194
- name: z.string().optional(),
195
- vertical: z.string().nullable().optional(),
236
+ kind: scopeText.optional(),
237
+ name: scopeText.optional(),
238
+ // The registry's grammar, as `resolveScopeRecord` holds it too: null or absent is "no vertical",
239
+ // and an empty string is refused here rather than stored.
240
+ vertical: verticalSlug.nullable().optional(),
196
241
  storageShape: storageShape.optional(),
197
242
  // The gate: only `global` is accepted until `eu`/`us` enforcement exists (K-32).
198
243
  // A request naming a jurisdiction we cannot yet honour is refused at the Zod
@@ -614,9 +659,6 @@ const modelUsageSummaryQuery = z.object({
614
659
  until: z.string().optional(),
615
660
  });
616
661
  export const DEFAULT_MODEL_MARGIN_PERCENT = 20;
617
- /** The upstream provider's trace handle, when a failure message carries one —
618
- * Cloudflare's `internal error; reference = <id>` shape (#559). */
619
- const UPSTREAM_REFERENCE = /\breference\s*=\s*([a-z0-9]+)/i;
620
662
  const TENANT_ROUTES = [
621
663
  { method: 'GET', re: /\/verticals\/[^/]+\/email-sender$/, pin: 'owner' },
622
664
  { method: 'POST', re: /\/verticals\/[^/]+\/email-sender$/, pin: 'owner' },
@@ -1037,16 +1079,34 @@ export function createControlPlaneApi(options) {
1037
1079
  const cause = c.get('failure');
1038
1080
  const marked = typeof cause === 'object' && cause !== null && recordedCauses.has(cause);
1039
1081
  if (!marked) {
1040
- const { vertical, tenantId } = await failureSubject(c);
1041
- recordFailure({
1042
- actor,
1043
- operation: `${c.req.method} ${c.req.routePath}`,
1044
- vertical,
1045
- tenantId,
1046
- scopeId: c.req.param('scopeId') ?? null,
1047
- status,
1048
- message: cause !== undefined ? messageOfThrow(cause) : await answeredMessage(c.res),
1049
- }, cause === undefined ? NO_CAUSE : cause);
1082
+ const { vertical, tenantId } = c.get('failureSubjectOf') ?? (await failureSubject(c));
1083
+ const scopeId = c.req.param('scopeId') ?? null;
1084
+ const base = { actor, operation: `${c.req.method} ${c.req.routePath}`, vertical, scopeId, status };
1085
+ const answered = await answeredMessage(c.res);
1086
+ const raw = cause !== undefined ? messageOfThrow(cause) : answered;
1087
+ if (tenantId !== null && raw !== answered) {
1088
+ // #2540: a row attributed to a tenant is read back by that tenant, so it says what this
1089
+ // request was answered, whoever made it. The throw's own text is unreviewed: it goes on a
1090
+ // row with no tenant, which only staff read, and to the log line, under the same reference.
1091
+ let reference = c.get('failureReference');
1092
+ if (!reference) {
1093
+ reference = ulid();
1094
+ console.error('control-plane.unhandled', { method: c.req.method, path: c.req.path, reference, tenantId, scopeId, detail: raw });
1095
+ }
1096
+ recordWithheld({ ...base, tenantId, reference }, raw, answered, cause);
1097
+ }
1098
+ else {
1099
+ recordFailure({
1100
+ ...base,
1101
+ tenantId,
1102
+ message: raw,
1103
+ // The one the log line carries. `recordFailure` keeps an upstream handle in the text
1104
+ // off a tenant's row, on a staff row beside it, and takes it on a row with no tenant.
1105
+ reference: tenantId
1106
+ ? (c.get('failureReference') ?? null)
1107
+ : (upstreamHandleIn(raw) ?? c.get('failureReference') ?? null),
1108
+ }, cause === undefined ? NO_CAUSE : cause);
1109
+ }
1050
1110
  }
1051
1111
  }
1052
1112
  if (inflightRecords.size) {
@@ -1282,6 +1342,13 @@ export function createControlPlaneApi(options) {
1282
1342
  // `undefined` (`throw undefined`, a bare rejection), and that throw deserves an
1283
1343
  // `unknown` attribution, not a skipped one. Only true omission skips.
1284
1344
  const NO_CAUSE = Symbol('no-cause');
1345
+ /**
1346
+ * #2540: the one kind of tenant row whose reference is an upstream trace handle. A failed upload
1347
+ * of a builder's own bundle answers the builder the upload's text verbatim, handle included, and
1348
+ * the CLI prints that handle for its lookup (#559), so the row keeps it as before. Every other
1349
+ * row filed under a tenant carries the platform's own reference. Named, never inferred from text.
1350
+ */
1351
+ const UPSTREAM_REFERENCE_KEPT = new Set(['deploy.upload']);
1285
1352
  // The throws a step already recorded, so the 5xx backstop below does not record them twice.
1286
1353
  const recordedCauses = new WeakSet();
1287
1354
  // The writes still in flight. A Worker may end once its response is sent, and a write nobody
@@ -1295,17 +1362,80 @@ export function createControlPlaneApi(options) {
1295
1362
  // regex a message. A caller with no throw in hand leaves the columns null,
1296
1363
  // which reads as "nobody classified this" rather than a guess nobody made.
1297
1364
  const attributed = cause === NO_CAUSE ? undefined : attributeFailure(cause);
1365
+ // #2540: a row filed under a tenant is read back by it, so its reference is always the
1366
+ // platform's own. When its text carries an upstream trace handle (#559), that handle is kept
1367
+ // on a staff row beside it (`detail`, under the same reference), where staff's search finds it.
1368
+ // A row with no tenant is staff's: its text is written as a staff row's always is
1369
+ // (`withUpstreamReference`: any marker spelling neutralised, a genuine handle's marker first),
1370
+ // and it takes that handle as its reference, as before.
1371
+ const handle = upstreamHandleIn(entry.message);
1372
+ const staffMessage = entry.tenantId ? entry.message : withUpstreamReference(entry.message);
1373
+ const shaped = !entry.tenantId
1374
+ ? { ...entry, message: staffMessage, reference: entry.reference ?? upstreamReferenceOf(staffMessage) }
1375
+ : UPSTREAM_REFERENCE_KEPT.has(entry.operation)
1376
+ ? { ...entry, reference: entry.reference ?? handle }
1377
+ : entry.detail == null && handle
1378
+ ? {
1379
+ ...entry,
1380
+ reference: entry.reference && entry.reference !== handle ? entry.reference : ulid(),
1381
+ detail: withUpstreamReference(entry.message),
1382
+ }
1383
+ : { ...entry, reference: entry.reference ?? null };
1298
1384
  const write = host.admin
1299
1385
  .recordOpsFailure({
1300
- ...entry,
1386
+ ...shaped,
1301
1387
  origin: entry.origin ?? attributed?.origin ?? null,
1302
1388
  code: entry.code ?? attributed?.code ?? null,
1303
- reference: entry.reference ?? UPSTREAM_REFERENCE.exec(entry.message)?.[1] ?? null,
1389
+ reference: shaped.reference ?? null,
1304
1390
  })
1305
1391
  .catch(() => undefined)
1306
1392
  .finally(() => inflightRecords.delete(write));
1307
1393
  inflightRecords.add(write);
1308
1394
  };
1395
+ /** #2540: whether this request's principal is staff. Anything else, or no principal yet, is confined. */
1396
+ const staffCaller = (c) => c.get('principal')?.kind === 'staff';
1397
+ /**
1398
+ * #2540: one failure whose text is not ours to hand a tenant. Filed under a tenant, it is one
1399
+ * write with a `detail`: the host puts what was answered on the tenant's row, which the tenant
1400
+ * reads back, and the text on a row with no tenant under the same reference, which only staff
1401
+ * read, and counts the failure once. With no tenant, the text is the row's message.
1402
+ */
1403
+ const recordWithheld = (entry, raw, answered, cause) => {
1404
+ // The pair is the one the host-level writers file too (`withheldOpsFailure`, #2554): an upstream
1405
+ // trace handle the text carries is named on staff's row only, first, so either id finds the pair.
1406
+ // With no tenant, `recordFailure` writes the text as a staff row's.
1407
+ const { tenantId } = entry;
1408
+ recordFailure(tenantId ? withheldOpsFailure({ ...entry, tenantId }, raw, () => answered, entry.reference) : { ...entry, tenantId: null, message: raw }, cause);
1409
+ };
1410
+ /**
1411
+ * #2540: a failure whose text is not ours to hand a confined caller: what an upstream or a store
1412
+ * answered, often while the request reached scopes that are not the caller's (a promote's serve
1413
+ * and store backfill run across every install). The text goes to the `control-plane.unhandled`
1414
+ * log line and to an ops-failure row with no tenant on it, which only staff read, both under one
1415
+ * reference (`recordWithheld`). A `tenantId` the failure belongs to gets a row too, saying only
1416
+ * `answered(reference)`. The answer carries the reference, and the text too when the caller is
1417
+ * staff. Who the caller is is decided by the request's principal alone (`staffCaller`), never by a
1418
+ * scope or tenant the request names. The cause is marked recorded, so the 5xx backstop writes no
1419
+ * second row for it; a route that answers the failure as a 5xx of its own also sets `failureRecorded`.
1420
+ */
1421
+ const withheldFailure = (c, entry, cause, answered) => {
1422
+ const reference = ulid();
1423
+ console.error('control-plane.unhandled', {
1424
+ method: c.req.method,
1425
+ path: c.req.path,
1426
+ reference,
1427
+ operation: entry.operation,
1428
+ stage: entry.stage ?? null,
1429
+ vertical: entry.vertical ?? null,
1430
+ version: entry.version ?? null,
1431
+ tenantId: entry.tenantId ?? null,
1432
+ scopeId: entry.scopeId ?? null,
1433
+ detail: entry.message,
1434
+ });
1435
+ const { message, ...rest } = entry;
1436
+ recordWithheld({ ...rest, actor: c.get('actor'), tenantId: answered ? (entry.tenantId ?? null) : null, reference }, message, answered?.(reference) ?? '', cause);
1437
+ return staffCaller(c) ? { reference, detail: message } : { reference };
1438
+ };
1309
1439
  /**
1310
1440
  * Whose failure the backstop is recording, so the dashboard's tenant-pinned failure view lists
1311
1441
  * it. The path's tenant when it names one; otherwise the credential's own (a CI push token, a
@@ -1369,7 +1499,7 @@ export function createControlPlaneApi(options) {
1369
1499
  // One error boundary for every route: adapters throw plain Errors, and each
1370
1500
  // one is a fail-closed refusal that must reach the caller as a status, not a
1371
1501
  // stack trace.
1372
- app.onError((err, c) => {
1502
+ app.onError(async (err, c) => {
1373
1503
  // A parse failure goes through the same builder as everything else (#113 phase 4):
1374
1504
  // `toProblem` maps zod's `issues` onto the declared `errors: [{ path, message }]`
1375
1505
  // extension, which is the shape the model documents and a client can act on. The raw
@@ -1382,6 +1512,31 @@ export function createControlPlaneApi(options) {
1382
1512
  if (err instanceof CarrySourceChanged) {
1383
1513
  return refuse(c, 'conflict', err.message, { carrySourceChanged: { scopeId: err.scopeId, from: err.from, to: err.to, attempts: err.attempts } });
1384
1514
  }
1515
+ // Whose failure this is, as the backstop files it, so a log line names the same tenant as its row.
1516
+ // Read only for an answer that is logged or filed: a 4xx refusal pays nothing.
1517
+ const subjectOf = async () => {
1518
+ const subject = await failureSubject(c).catch(() => ({ vertical: null, tenantId: null }));
1519
+ c.set('failureSubjectOf', subject);
1520
+ return subject;
1521
+ };
1522
+ // #2540: a refusal to judge what a promote or bind breaks names what could not be read, which may
1523
+ // be another tenant's app, and that read's own error. Every door that reaches it answers here.
1524
+ if (isExportBreakUnjudged(err)) {
1525
+ const subject = await subjectOf();
1526
+ const sentence = (reference) => `cannot say whom this change breaks: what an installed app imports could not be read, so nothing was changed. Retry (reference ${reference})`;
1527
+ const withheld = withheldFailure(c, {
1528
+ operation: `${c.req.method} ${c.req.routePath}`,
1529
+ stage: 'export-breaks',
1530
+ vertical: subject.vertical,
1531
+ tenantId: subject.tenantId,
1532
+ scopeId: c.req.param('scopeId') ?? null,
1533
+ status: 503,
1534
+ code: 'unavailable',
1535
+ message: messageOfThrow(err),
1536
+ }, err, sentence);
1537
+ c.set('failureRecorded', true);
1538
+ return refuse(c, 'unavailable', sentence(withheld.reference), withheld);
1539
+ }
1385
1540
  const { status, body } = mapError(err);
1386
1541
  // The 5xx backstop (registered first, above) records it, with this throw as the cause.
1387
1542
  c.set('failure', err);
@@ -1394,9 +1549,16 @@ export function createControlPlaneApi(options) {
1394
1549
  // (the 503 for a plane with no seal key, #603) passes through here too: its body already
1395
1550
  // says what is wrong, and the tail line is what ties it to the request that hit it.
1396
1551
  if (status >= 500) {
1552
+ const subject = await subjectOf();
1553
+ const reference = ulid();
1554
+ c.set('failureReference', reference);
1397
1555
  console.error('control-plane.unhandled', {
1398
1556
  method: c.req.method,
1399
1557
  path: c.req.path,
1558
+ reference,
1559
+ tenantId: subject.tenantId,
1560
+ vertical: subject.vertical,
1561
+ scopeId: c.req.param('scopeId') ?? null,
1400
1562
  detail: err instanceof Error ? err.message : String(err),
1401
1563
  stack: err instanceof Error ? err.stack : undefined,
1402
1564
  });
@@ -1531,11 +1693,13 @@ export function createControlPlaneApi(options) {
1531
1693
  return c.json(await c.var.admin.listEntitlements(c.get('actor'), tenantId));
1532
1694
  });
1533
1695
  // -- the meters (§5, #38) --------------------------------------------------
1534
- // Meters 1 and 2, read fleet-wide or narrowed with `?tenantId=`. Meters 3 and 4 have
1535
- // no route because they have no number: the outbox is per-scope-database with no
1536
- // cross-tenant fan-in and reads emit nothing, so anything served here would be
1537
- // invented. Staff-only (absent from BUILDER_ROUTES) — a fleet-wide revenue aggregate
1538
- // is the platform's own book, not a builder's view of their installs.
1696
+ // Meters 1 and 2, read fleet-wide or narrowed with `?tenantId=`. Meter 3's parts are
1697
+ // read at their own routes, not here (`/meters/storage`, `/model-usage`, and the estimated
1698
+ // daily request rollup at `/meters/requests`); lake bytes have no reader yet, and exact API counts are not computable, since
1699
+ // reads emit nothing on the spine. Meter 4 has no route because it has no number
1700
+ // (it needs the cross-tenant order flow). See control-plane.md §5. Staff-only (absent
1701
+ // from BUILDER_ROUTES) — a fleet-wide revenue aggregate is the platform's own book, not
1702
+ // a builder's view of their installs.
1539
1703
  //
1540
1704
  // GET, and nothing is stored: D-30 is meter, do not bill, so a reading is recomputed
1541
1705
  // per call and stamped with the instant it was taken.
@@ -1580,14 +1744,123 @@ export function createControlPlaneApi(options) {
1580
1744
  if (!scope.vertical)
1581
1745
  return c.var.admin.scopeDatabaseSize(actor, q.tenantId, scope.id);
1582
1746
  const vertical = await verticalForScope(c, scope);
1583
- if (vertical)
1584
- return vertical.databaseSize(scope.id);
1747
+ if (vertical) {
1748
+ try {
1749
+ return await vertical.databaseSize(scope.id);
1750
+ }
1751
+ catch (err) {
1752
+ // #2541: a deployment that holds nothing for a scope the directory has reaped or archived
1753
+ // since the listing (or does not have) reads 0; any other status holds a store and stays a
1754
+ // failure here, as does a directory read that fails.
1755
+ if (!isScopeNotHeld(err, scope.id))
1756
+ throw err;
1757
+ if (!(await notHeldIsBenign(() => c.var.admin.getScopeRecord(actor, q.tenantId, scope.id))))
1758
+ throw err;
1759
+ return 0;
1760
+ }
1761
+ }
1585
1762
  if (delegatesToVerticals)
1586
1763
  throw new Error(`no deployment resolves for vertical ${scope.vertical}`);
1587
1764
  return c.var.admin.scopeDatabaseSize(actor, q.tenantId, scope.id);
1588
1765
  },
1589
1766
  }));
1590
1767
  });
1768
+ // The daily request rollup (#2501): the stored per-(day, tenant, vertical, scope) request
1769
+ // counts the platform sweep reads from the router's Analytics Engine datapoints before they
1770
+ // age out, and the rollup's record of each day in the window. Staff-only like `/meters`. Every
1771
+ // count is an estimate (Analytics Engine samples), and the answer says so twice: `estimated`
1772
+ // on the reading and on every row. A directory read; nothing is queried live. `days` is what
1773
+ // tells a day without traffic from a day not yet rolled up.
1774
+ //
1775
+ // A page is at most half the directory's own read bound, so the one row past the page that
1776
+ // says the window holds more always fits under it.
1777
+ const REQUEST_ROLLUP_PAGE_MAX = REQUEST_ROLLUP_LIMIT_MAX / 2;
1778
+ // Unless a window is named, the 31 days ending at `until`, both ends included.
1779
+ const windowSince = (until) => new Date(Date.parse(`${until}T00:00:00Z`) - 30 * 86_400_000).toISOString().slice(0, 10);
1780
+ app.get('/meters/requests', async (c) => {
1781
+ const admin = c.var.admin;
1782
+ if (!admin.listRequestDays || !admin.listRequestRollupDays) {
1783
+ return refuseStatus(c, 501, 'this control plane keeps no request rollup');
1784
+ }
1785
+ const today = new Date().toISOString().slice(0, 10);
1786
+ const q = z
1787
+ .object({
1788
+ tenantId: tenantIdSchema.optional(),
1789
+ scopeId: scopeIdSchema.optional(),
1790
+ vertical: z.string().min(1).optional(),
1791
+ since: utcDay.optional(),
1792
+ until: utcDay.default(today),
1793
+ limit: z.coerce.number().int().min(1).max(REQUEST_ROLLUP_PAGE_MAX).default(REQUEST_ROLLUP_LIMIT_DEFAULT),
1794
+ })
1795
+ .parse({
1796
+ tenantId: c.req.query('tenantId') || undefined,
1797
+ scopeId: c.req.query('scopeId') || undefined,
1798
+ vertical: c.req.query('vertical') || undefined,
1799
+ since: c.req.query('since') || undefined,
1800
+ until: c.req.query('until') || undefined,
1801
+ limit: c.req.query('limit') || undefined,
1802
+ });
1803
+ const since = q.since ?? windowSince(q.until);
1804
+ if (since > q.until)
1805
+ return refuse(c, 'validation_failed', '`since` is after `until`');
1806
+ const actor = c.get('actor');
1807
+ // One row past the page says whether the window holds more than it returned.
1808
+ const rows = await admin.listRequestDays(actor, {
1809
+ ...(q.tenantId ? { tenantId: q.tenantId } : {}),
1810
+ ...(q.scopeId ? { scopeId: q.scopeId } : {}),
1811
+ ...(q.vertical ? { vertical: q.vertical } : {}),
1812
+ since,
1813
+ until: q.until,
1814
+ limit: q.limit + 1,
1815
+ });
1816
+ const days = await admin.listRequestRollupDays(actor, { since, until: q.until });
1817
+ return c.json(requestRollupReading.parse({
1818
+ estimated: true,
1819
+ since,
1820
+ until: q.until,
1821
+ rows: rows.slice(0, q.limit),
1822
+ truncated: rows.length > q.limit,
1823
+ days,
1824
+ }));
1825
+ });
1826
+ // The daily meter snapshot (#2501): meters 1 and 2 as the first scheduled pass of each UTC day
1827
+ // found them, one row per (day, tenant), and the record of each day in the window. Staff-only
1828
+ // like `/meters`. A directory read; nothing is recomputed, so a past day reads as it was then.
1829
+ // `days` is what tells a day no pass took from a day whose snapshot met no tenant. The page bound
1830
+ // leaves room under the directory's own for the one row past it.
1831
+ const METER_DAYS_PAGE_MAX = METER_DAYS_LIMIT_MAX / 2;
1832
+ app.get('/meters/days', async (c) => {
1833
+ const admin = c.var.admin;
1834
+ if (!admin.listMeterDays || !admin.listMeterDayTakes) {
1835
+ return refuseStatus(c, 501, 'this control plane keeps no meter snapshots');
1836
+ }
1837
+ const today = new Date().toISOString().slice(0, 10);
1838
+ const q = z
1839
+ .object({
1840
+ tenantId: tenantIdSchema.optional(),
1841
+ since: utcDay.optional(),
1842
+ until: utcDay.default(today),
1843
+ limit: z.coerce.number().int().min(1).max(METER_DAYS_PAGE_MAX).default(METER_DAYS_LIMIT_DEFAULT),
1844
+ })
1845
+ .parse({
1846
+ tenantId: c.req.query('tenantId') || undefined,
1847
+ since: c.req.query('since') || undefined,
1848
+ until: c.req.query('until') || undefined,
1849
+ limit: c.req.query('limit') || undefined,
1850
+ });
1851
+ const since = q.since ?? windowSince(q.until);
1852
+ if (since > q.until)
1853
+ return refuse(c, 'validation_failed', '`since` is after `until`');
1854
+ const actor = c.get('actor');
1855
+ const rows = await admin.listMeterDays(actor, {
1856
+ ...(q.tenantId ? { tenantId: q.tenantId } : {}),
1857
+ since,
1858
+ until: q.until,
1859
+ limit: q.limit + 1,
1860
+ });
1861
+ const days = await admin.listMeterDayTakes(actor, { since, until: q.until });
1862
+ return c.json(meterDaysReading.parse({ since, until: q.until, rows: rows.slice(0, q.limit), truncated: rows.length > q.limit, days }));
1863
+ });
1591
1864
  // -- per-tenant stores (#301, #473) ----------------------------------------
1592
1865
  // The two ledgers as INVENTORY — what `listTenantStores`/`listBlobStores` were always
1593
1866
  // meant to answer for a staff surface: which database and which bucket hold this
@@ -3196,7 +3469,7 @@ export function createControlPlaneApi(options) {
3196
3469
  };
3197
3470
  /** Whether the scope's store in `holder` is a wiped copy: it holds the `carried_away` tombstone. */
3198
3471
  const isCarriedAway = async (holder, scope) => {
3199
- const page = await holder.readScopeTable(scope.id, { table: '_substrat_meta', limit: 100, offset: 0 });
3472
+ const page = await holder.readTombstoneTable(scope.id, { table: '_substrat_meta', limit: 100, offset: 0 });
3200
3473
  return metaValueIn(page.rows, page.columns, CARRIED_AWAY_KEY) !== null;
3201
3474
  };
3202
3475
  const recordCarryCleanup = (c, scope, versionId, stage, e) => {
@@ -3565,12 +3838,24 @@ export function createControlPlaneApi(options) {
3565
3838
  return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3566
3839
  // Health reads the scope the same way the Data view does, so it leaves the same
3567
3840
  // row: an auditor should not be able to tell which route reached the tables.
3841
+ // #2544: a deployment that does not hold the scope refuses the table read. Health is the one
3842
+ // reader that wants the answer "nothing stored" for it: the scope has no role rows, which is
3843
+ // what `roleProjectionEmpty` reports for an active one, rather than a refusal of the whole route.
3844
+ let notHeld = false;
3568
3845
  const tables = await delegatedRead(c, tenantId, scopeId, scope, 'listScopeTables', null, {
3569
3846
  viaVertical: (v) => v.listScopeTables(scopeId),
3570
3847
  colocated: () => c.var.admin.listScopeTables(c.get('actor'), tenantId, scopeId),
3571
- }, (r) => r.length);
3848
+ }, (r) => r.length).catch((err) => {
3849
+ if (!isScopeNotHeld(err, scopeId))
3850
+ throw err;
3851
+ notHeld = true;
3852
+ return [];
3853
+ });
3572
3854
  const roles = tables.find((t) => t.name === '_substrat_roles');
3573
- const roleCount = roles ? roles.rowCount : null;
3855
+ // #2531: the roles in force. A dropped role is kept as a tombstone, so the table's own row count
3856
+ // would read a projection that defines nothing as populated. A host from before tombstones
3857
+ // answers no `liveRowCount`, and kept none, so its `rowCount` is that count.
3858
+ const roleCount = notHeld ? 0 : roles ? (roles.liveRowCount ?? roles.rowCount) : null;
3574
3859
  const roleProjectionEmpty = scope.status === 'active' && roleCount === 0;
3575
3860
  const missingStores = scope.vertical
3576
3861
  ? await missingStoresForTenant({
@@ -3707,15 +3992,18 @@ export function createControlPlaneApi(options) {
3707
3992
  // ready-gate and a fresh install without it is simply broken, so a failed mint must
3708
3993
  // fail the provision rather than hand back a half-built scope.
3709
3994
  const storeErrors = [];
3995
+ // #2540: the same failures as the scope's tenant may read them: the sentences, never the text.
3996
+ const storeSentences = [];
3710
3997
  const mintBestEffort = async (stage, mint) => {
3711
3998
  try {
3712
3999
  return await mint();
3713
4000
  }
3714
4001
  catch (e) {
3715
- const message = e instanceof Error ? e.message : String(e);
3716
- console.error('stores.provision.failed', { tenantId, scopeId, vertical: scope.vertical, stage, detail: message });
3717
- recordFailure({
3718
- actor,
4002
+ // #2540: what the store answered is staff's. A confined caller (a builder may repair any
4003
+ // install of its own vertical, whoever's) reads that the mint failed and the reference, and
4004
+ // the scope's tenant reads the same on its failure row, whoever made the request.
4005
+ const sentence = (reference) => `a declared store could not be minted (${stage}, reference ${reference})`;
4006
+ const withheld = withheldFailure(c, {
3719
4007
  operation: 'scope.provision.stores',
3720
4008
  stage,
3721
4009
  tenantId,
@@ -3723,9 +4011,10 @@ export function createControlPlaneApi(options) {
3723
4011
  vertical: scope.vertical,
3724
4012
  version: scope.verticalVersionId ?? null,
3725
4013
  status: e instanceof ControlPlaneError ? e.status : null,
3726
- message,
3727
- }, e);
3728
- storeErrors.push(message);
4014
+ message: messageOfThrow(e),
4015
+ }, e, sentence);
4016
+ storeErrors.push(withheld.detail ? `${withheld.detail} (reference ${withheld.reference})` : sentence(withheld.reference));
4017
+ storeSentences.push(sentence(withheld.reference));
3729
4018
  return undefined;
3730
4019
  }
3731
4020
  };
@@ -3803,8 +4092,60 @@ export function createControlPlaneApi(options) {
3803
4092
  // Both halves failed. The store fault is usually the CAUSE (an unmigrated or
3804
4093
  // unbound store is exactly what makes a vertical refuse its own reconcile), so
3805
4094
  // it must not be the half that gets dropped.
3806
- const detail = storeErrors.length ? `${e.message} (stores: ${storeErrors.join('; ')})` : e.message;
3807
- return refuseStatus(c, failedWith(c, e), detail);
4095
+ const stores = storeErrors.length ? ` (stores: ${storeErrors.join('; ')})` : '';
4096
+ // #2540: the vertical's answer about a scope is its tenant's own text. Staff, and a confined
4097
+ // caller repairing an install of its own tenant, read it as before.
4098
+ if (staffCaller(c) || confinedTenant(c.get('principal')) === tenantId) {
4099
+ const answer = `${e.message}${stores}`;
4100
+ // Staff are answered the store text too, which the tenant's row must not say: a 5xx the
4101
+ // backstop would file is filed here as a pair, the tenant's row with the sentences only.
4102
+ const tenantStores = storeSentences.length ? ` (stores: ${storeSentences.join('; ')})` : '';
4103
+ if (e.status >= 500 && e.status !== 501 && tenantStores !== stores) {
4104
+ const reference = ulid();
4105
+ recordWithheld({
4106
+ actor,
4107
+ operation: `${c.req.method} ${c.req.routePath}`,
4108
+ tenantId,
4109
+ scopeId,
4110
+ vertical: scope.vertical,
4111
+ version: scope.verticalVersionId ?? null,
4112
+ status: e.status,
4113
+ reference,
4114
+ }, answer, `${e.message}${tenantStores}`, e);
4115
+ c.set('failureRecorded', true);
4116
+ return refuseStatus(c, failedWith(c, e), answer, { reference });
4117
+ }
4118
+ return refuseStatus(c, failedWith(c, e), answer);
4119
+ }
4120
+ // A builder repairing another tenant's install of its vertical reads the status, the store
4121
+ // sentences and a reference; what that deployment answered is staff's.
4122
+ const sentence = (reference) => `the reconcile of this scope failed (${e.status})${stores} (reference ${reference})`;
4123
+ if (e.status < 500) {
4124
+ // A refusal, which the 5xx backstop leaves unrecorded for staff too: logged, no row.
4125
+ const reference = ulid();
4126
+ console.warn('control-plane.withheld', {
4127
+ method: c.req.method,
4128
+ path: c.req.path,
4129
+ reference,
4130
+ operation: 'scope.provision.reconcile',
4131
+ tenantId,
4132
+ scopeId,
4133
+ status: e.status,
4134
+ detail: e.message,
4135
+ });
4136
+ return refuseStatus(c, e.status, sentence(reference), { reference });
4137
+ }
4138
+ const withheld = withheldFailure(c, {
4139
+ operation: 'scope.provision.reconcile',
4140
+ tenantId,
4141
+ scopeId,
4142
+ vertical: scope.vertical,
4143
+ version: scope.verticalVersionId ?? null,
4144
+ status: e.status,
4145
+ message: e.message,
4146
+ }, e, sentence);
4147
+ c.set('failureRecorded', true);
4148
+ return refuseStatus(c, e.status, sentence(withheld.reference), withheld);
3808
4149
  }
3809
4150
  throw e;
3810
4151
  }
@@ -4507,6 +4848,7 @@ export function createControlPlaneApi(options) {
4507
4848
  scopeId: snapId,
4508
4849
  kind: opts.kind ?? 'archive',
4509
4850
  vertical: scope.vertical,
4851
+ verticalSource: 'copy',
4510
4852
  jurisdiction: scope.jurisdiction,
4511
4853
  forkedFrom: scope.id,
4512
4854
  forkedAt: new Date().toISOString(),
@@ -6901,6 +7243,20 @@ export function createControlPlaneApi(options) {
6901
7243
  await c.var.admin.deleteVertical(c.get('actor'), slug);
6902
7244
  return c.json({ slug, deleted: true });
6903
7245
  });
7246
+ // Who owned a vertical slug when (#2501): every period, opened when the slug was registered and
7247
+ // closed when it was deleted, so a past period bills the builder who owned the vertical then.
7248
+ // Staff-only, like `/meters`: absent from BUILDER_ROUTES and from the tenant credential's pins.
7249
+ // Served for a deleted slug too, which is the point; a slug never registered has no period.
7250
+ // The FULL slug, read as given: unlike the routes behind `resolveVerticalId`, the tenant pin is
7251
+ // ignored, since it resolves a bare name through the registry row a deleted slug no longer has.
7252
+ // A bare name is read as a slug of its own; one never registered answers with no periods.
7253
+ app.get('/verticals/:slug/owners', async (c) => {
7254
+ const admin = c.var.admin;
7255
+ if (!admin.listVerticalOwners)
7256
+ return refuseStatus(c, 501, 'this control plane keeps no ownership history');
7257
+ const slug = verticalSlug.parse(c.req.param('slug'));
7258
+ return c.json(verticalOwnersReading.parse({ slug, owners: await admin.listVerticalOwners(c.get('actor'), slug) }));
7259
+ });
6904
7260
  app.get('/verticals/:slug/channels', async (c) => {
6905
7261
  const p = c.get('principal');
6906
7262
  const slug = await resolveVerticalId(c, c.req.param('slug'));
@@ -6987,6 +7343,40 @@ export function createControlPlaneApi(options) {
6987
7343
  await host.admin.releaseStoreDriverHold(actor, { driver, holder }).catch(() => undefined);
6988
7344
  }
6989
7345
  };
7346
+ /**
7347
+ * Upload `bundle` to the script `ref`, a deployment of `slug`, with what the control plane's
7348
+ * settings give it for the envelope's verifier (RFC wire-signing step 2, follow-on 3:
7349
+ * `wireUploadFor`) added to its bindings. Both uploads of a vertical's script go through here, so
7350
+ * no upload path can leave the lists out. A refused setting or refused lists give nothing, and are
7351
+ * logged and recorded as an operational failure by reason, never with a key. Answers what the
7352
+ * upload gave, for its record (`recordWireUpload`).
7353
+ */
7354
+ const deployVerticalScript = async (deploy, actor, slug, ref, bundle, inPlace) => {
7355
+ const wire = await wireUploadFor(options.wireUpload, { vertical: slug, deploymentRef: ref, bundle });
7356
+ if (wire.refusal !== null) {
7357
+ console.error(JSON.stringify({ substrat: 'wire-upload', vertical: slug, deploymentRef: ref, withheld: wire.record.withheld, reason: wire.refusal }));
7358
+ recordFailure({ actor, operation: 'deploy.wire-upload', stage: wire.record.withheld, vertical: slug, message: `no key lists given to ${ref}: ${wire.refusal}` });
7359
+ }
7360
+ await deploy(ref, { ...bundle, bindings: [...bundle.bindings, ...wire.bindings] }, inPlace);
7361
+ return wire;
7362
+ };
7363
+ /**
7364
+ * The capability record of an upload that succeeded (RFC wire-signing §7, Q8). A write that fails
7365
+ * never fails the upload, which is done: it is logged and recorded as an operational failure, and
7366
+ * the readiness report reads a deployment with no record as unknown, never as ready.
7367
+ */
7368
+ const recordWireUpload = async (admin, actor, versionId, wire) => {
7369
+ if (!admin.recordWireAuth)
7370
+ return;
7371
+ const { vertical, deploymentRef, record } = wire;
7372
+ try {
7373
+ await admin.recordWireAuth(actor, { deploymentRef, verticalSlug: vertical, versionId, record, recordedAt: new Date().toISOString() });
7374
+ }
7375
+ catch (error) {
7376
+ console.error(JSON.stringify({ substrat: 'wire-upload-record-failed', vertical, deploymentRef, error: error instanceof Error ? error.name : 'error' }));
7377
+ recordFailure({ actor, operation: 'deploy.wire-record', vertical, version: versionId, message: `the capability record of ${deploymentRef} was not written` }, error);
7378
+ }
7379
+ };
6990
7380
  /**
6991
7381
  * Serve a version IN PLACE (#286): upload its bundle onto the vertical's ONE stable
6992
7382
  * serving script, where every scope's data DO lives — this is the step that makes a
@@ -7041,7 +7431,7 @@ export function createControlPlaneApi(options) {
7041
7431
  : { type: 'r2_bucket', name: b.name, bucket_name: b.bucketName });
7042
7432
  // #2210: the store scripts first, so the vertical is never served before what it reaches.
7043
7433
  await ensureScopeStoresOf(actor, 'serve', slug, manifest.scopeStores, async () => (await ownerOf(actor, slug)) ?? null);
7044
- await options.deployVertical(ref, {
7434
+ const wire = await deployVerticalScript(options.deployVertical, actor, slug, ref, {
7045
7435
  entry: manifest.entry,
7046
7436
  compatibilityDate: manifest.compatibilityDate,
7047
7437
  compatibilityFlags: manifest.compatibilityFlags,
@@ -7095,6 +7485,7 @@ export function createControlPlaneApi(options) {
7095
7485
  }, serving
7096
7486
  ? { priorDoClasses: serving.doClasses, priorMigrationTag: serving.migrationTag }
7097
7487
  : undefined);
7488
+ await recordWireUpload(admin, actor, versionId, wire);
7098
7489
  const addedClasses = serving ? doClasses.some((cls) => !serving.doClasses.includes(cls)) : false;
7099
7490
  await admin.setVerticalServing(actor, slug, {
7100
7491
  ref,
@@ -7154,11 +7545,97 @@ export function createControlPlaneApi(options) {
7154
7545
  return { minted };
7155
7546
  }
7156
7547
  catch (e) {
7157
- const message = e instanceof Error ? e.message : String(e);
7158
- console.error('stores.backfill.failed', { slug, detail: message });
7159
- recordFailure({ actor, operation: 'promote.store-backfill', vertical: slug, message }, e);
7160
- return { minted: [], error: message };
7548
+ // The sweep mints across every installed tenant, so what failed is withheld from a confined
7549
+ // caller (#2540): it reads that the backfill failed, and the reference.
7550
+ const withheld = withheldFailure(c, { operation: 'promote.store-backfill', vertical: slug, message: messageOfThrow(e) }, e);
7551
+ return {
7552
+ minted: [],
7553
+ error: `${withheld.detail ?? 'the stores this version declares could not be minted for every install'} (reference ${withheld.reference})`,
7554
+ };
7555
+ }
7556
+ };
7557
+ /**
7558
+ * #2446: the scopes a prod promote of `versionId` would hold at their reconcile, judged before
7559
+ * anything moves. A pushed vertical's reconcile defines the incoming version's role set in each
7560
+ * scope it runs in (`refuseReservedRoleSet`), and refuses it where the scope's stored roles and
7561
+ * seats forbid it (#2525: a seated person's role made `holder: 'system'`, among others), which
7562
+ * holds that scope's whole reconcile while it serves the new code. So each scope the promote moves
7563
+ * onto the incoming version is asked for that state (`/internal/role-state`) and judged with the
7564
+ * reconcile's own rule and inputs (`releaseRoleSetRefusal`: the incoming registry's roles and
7565
+ * marks, which the push holds to the module declarations it carries).
7566
+ *
7567
+ * The scopes moved are the ones the in-place serve and the adopt after it move: every live primary
7568
+ * scope of the vertical already on its serving script, and, for a private vertical, every one the
7569
+ * owning tenant runs. Only a dispatch-backed version served in place moves them here; an embedded
7570
+ * vertical's roles are its host's, defined by no reconcile of the registry, so it is asked nothing.
7571
+ * Nor is a release no stored state can refuse (`releaseRolesAskScopes`), so a vertical with no
7572
+ * system-held role pays no read.
7573
+ *
7574
+ * At most `roleStateScopeCap` scopes are read, `ROLE_STATE_CONCURRENCY` at a time in (tenant,
7575
+ * scope) order; more is a 503 naming the two ways through (raise the cap, or staff's
7576
+ * `acknowledge.roleStateUnjudged`, which skips the read and is audited), never a partial
7577
+ * judgement. A deployment that cannot answer the read (one that predates the route, a 404, or one
7578
+ * whose scope host predates the method, its 501) is counted instead; its reconcile still refuses.
7579
+ * Any other failed read is collected in `failed`, with what the deployment said, and refuses the
7580
+ * promote with nothing moved. Nothing here is phrased for a caller: the route narrows every answer
7581
+ * to the scopes the caller may see, and what a deployment said reaches only the log and staff.
7582
+ */
7583
+ const releaseRoleRefusalsOf = async (c, slug, versionId) => {
7584
+ const none = { refused: [], failed: [], unread: 0 };
7585
+ if (!options.deployVertical || !options.fetchVerticalModules)
7586
+ return none; // no in-place serve moves anything
7587
+ const actor = c.get('actor');
7588
+ const incoming = await host.admin.getVersion(actor, versionId, slug);
7589
+ if (!incoming?.deploymentRef)
7590
+ return none;
7591
+ const manifestJson = await host.admin.versionManifest(actor, slug, versionId);
7592
+ const registry = manifestJson ? storedDeployManifest.parse(JSON.parse(manifestJson)).registry : undefined;
7593
+ if (!registry || !releaseRolesAskScopes(registry))
7594
+ return none;
7595
+ const v = await verticalOf(actor, slug);
7596
+ const owner = v && v.ownerTenant !== null && !v.listed ? v.ownerTenant : null;
7597
+ const moved = (await c.var.admin.listScopes(actor, { vertical: slug, status: ['active', 'suspended'] }))
7598
+ .filter((s) => !s.forkedFrom && s.kind !== 'preview' && (!!s.servingRef || (s.tenantId === owner && s.status === 'active')))
7599
+ .sort((a, b) => (a.tenantId === b.tenantId ? a.id.localeCompare(b.id) : a.tenantId.localeCompare(b.tenantId)));
7600
+ const cap = options.roleStateScopeCap ?? ROLE_STATE_SCOPE_CAP;
7601
+ if (moved.length > cap) {
7602
+ throw new ControlPlaneError(503, `this version declares a system-held role, so a promote judges the roles of every scope it moves before moving ` +
7603
+ `any, and it would move ${moved.length}, more than the ${cap} one promote reads. Nothing was promoted. Raise the control ` +
7604
+ 'plane\'s `roleStateScopeCap`, or, as staff, promote with `acknowledge.roleStateUnjudged`, which skips the read and ' +
7605
+ 'is recorded in the promote\'s audit row');
7161
7606
  }
7607
+ const refused = [];
7608
+ const failed = [];
7609
+ let unread = 0;
7610
+ for (let i = 0; i < moved.length; i += ROLE_STATE_CONCURRENCY) {
7611
+ const batch = moved.slice(i, i + ROLE_STATE_CONCURRENCY);
7612
+ const states = await Promise.all(batch.map(async (s) => {
7613
+ try {
7614
+ const client = await verticalForScope(c, s);
7615
+ if (!client)
7616
+ return { failed: 'no deployment serves it' };
7617
+ return await client.roleState({ tenantId: s.tenantId, scopeId: s.id });
7618
+ }
7619
+ catch (e) {
7620
+ return { failed: e instanceof Error ? e.message : String(e) };
7621
+ }
7622
+ }));
7623
+ batch.forEach((s, j) => {
7624
+ const state = states[j];
7625
+ if (state === 'predates')
7626
+ return void unread++;
7627
+ if ('failed' in state) {
7628
+ console.warn('promote.role-state.unreadable', { slug, versionId, tenantId: s.tenantId, scopeId: s.id, detail: state.failed });
7629
+ return void failed.push({ tenantId: s.tenantId, scopeId: s.id, detail: state.failed });
7630
+ }
7631
+ const refusal = releaseRoleSetRefusal(registry, state);
7632
+ if (refusal)
7633
+ refused.push({ tenantId: s.tenantId, scopeId: s.id, refusal });
7634
+ });
7635
+ }
7636
+ if (unread > 0)
7637
+ console.warn('promote.role-state.predates', { slug, versionId, scopes: unread });
7638
+ return { refused, failed, unread };
7162
7639
  };
7163
7640
  /**
7164
7641
  * Which installed apps promoting `versionId` to `channel` would break: the promote gate's own
@@ -7265,6 +7742,23 @@ export function createControlPlaneApi(options) {
7265
7742
  return { affected: visible, ...(otherTenants ? { otherTenants } : {}) };
7266
7743
  };
7267
7744
  const exportBreaks = acknowledge?.exportBreak ? await breaksOf() : null;
7745
+ // #2446: the scopes whose reconcile this version's roles would hold, before anything moves. The
7746
+ // digest and export-break acknowledgements do not pass it: the way through is taking the seats
7747
+ // back, or a release that keeps the role as each scope stores it. It runs ahead of the host's
7748
+ // admission and digest gates rather than being skipped when they look set to refuse: those are
7749
+ // judged inside `promoteVersion` against the channel as it stands then, so a read skipped on a
7750
+ // prediction would let a promote through unjudged if an admit or a channel move landed between.
7751
+ // The read is paid only by a release declaring a system-held role, at most the cap.
7752
+ // `roleStateUnjudged` skips the read: staff only, and recorded with the promote's acknowledgements.
7753
+ if (acknowledge?.roleStateUnjudged && confinedTenant(p) !== null) {
7754
+ return refuse(c, 'forbidden', 'acknowledge.roleStateUnjudged is for staff: it skips the role-state read a promote judges its roles with');
7755
+ }
7756
+ const roles = acknowledge?.roleStateUnjudged
7757
+ ? { refused: [], failed: [], unread: 0 }
7758
+ : await releaseRoleRefusalsOf(c, slug, versionId);
7759
+ const answer = roleStateAnswer(roles, confinedTenant(p));
7760
+ if (answer)
7761
+ return refuseStatus(c, answer.status, answer.message, answer.extra);
7268
7762
  // The blast-radius moment: refuses a changed digest without the acknowledgement,
7269
7763
  // and refuses a non-admitted version. Both are enforced below the seam and
7270
7764
  // surface as a 4xx through mapError, not a 500.
@@ -7276,10 +7770,16 @@ export function createControlPlaneApi(options) {
7276
7770
  throw err;
7277
7771
  // The refusal stands whether or not the listing can be read. A listing that fails turns
7278
7772
  // into a note, never into a 500 that would hide that the promote was refused.
7279
- const listing = await breaksOf().then((exportBreaks) => ({ exportBreaks }), (e) => ({
7280
- exportBreaks: null,
7281
- exportBreaksUnavailable: `which apps it breaks could not be read: ${e instanceof Error ? e.message : String(e)}`,
7282
- }));
7773
+ // The listing is read across every tenant that installed the vertical, so what failed is
7774
+ // withheld from a confined caller (#2540).
7775
+ const listing = await breaksOf().then((exportBreaks) => ({ exportBreaks }), (e) => {
7776
+ const withheld = withheldFailure(c, { operation: 'promote.export-breaks', vertical: slug, version: versionId, message: messageOfThrow(e) }, e);
7777
+ return {
7778
+ exportBreaks: null,
7779
+ exportBreaksUnavailable: `which apps it breaks could not be read: ${withheld.detail ?? 'the platform recorded why'} ` +
7780
+ `(reference ${withheld.reference})`,
7781
+ };
7782
+ });
7283
7783
  return refuse(c, 'conflict', listing.exportBreaks ? exportBreakRefusal(counted) : err.message, listing);
7284
7784
  }
7285
7785
  // The in-place serve (#286), prod only, AFTER every promote gate has passed —
@@ -7304,9 +7804,21 @@ export function createControlPlaneApi(options) {
7304
7804
  backfill = await backfillFleetStores(c, slug);
7305
7805
  }
7306
7806
  catch (e) {
7307
- const detail = e instanceof Error ? e.message : String(e);
7308
- console.error('serve.inplace.failed', { slug, versionId, detail });
7309
- return refuseStatus(c, 502, 'promoted, but the in-place serve failed — scopes still run the previous code; promote again to retry', { detail });
7807
+ // The serve, the adopt and the backfill reach every install of the vertical, whoever's, so
7808
+ // what failed is withheld from a confined caller (#2540), and the tenant the backstop would
7809
+ // have filed it under reads only the sentence.
7810
+ const sentence = (reference) => `promoted, but the in-place serve failed — scopes still run the previous code; promote again to retry (reference ${reference})`;
7811
+ const withheld = withheldFailure(c, {
7812
+ operation: 'promote.serve',
7813
+ stage: 'serve-in-place',
7814
+ vertical: slug,
7815
+ version: versionId,
7816
+ tenantId: (await failureSubject(c)).tenantId,
7817
+ status: 502,
7818
+ message: messageOfThrow(e),
7819
+ }, e, sentence);
7820
+ c.set('failureRecorded', true);
7821
+ return refuseStatus(c, 502, sentence(withheld.reference), withheld);
7310
7822
  }
7311
7823
  }
7312
7824
  // #1756: served, and every install that could move has moved and been backfilled, but one
@@ -7350,6 +7862,8 @@ export function createControlPlaneApi(options) {
7350
7862
  ...promoted,
7351
7863
  // What the acknowledged break reached, so a promoter who passed `exportBreak` reads it.
7352
7864
  ...(exportBreaks ? { exportBreaks } : {}),
7865
+ // #2446: scopes whose deployment predates the role-state read were moved unjudged, in counts.
7866
+ ...(roles.unread > 0 ? { roleStateUnread: { scopes: roles.unread, note: ROLE_STATE_UNREAD_NOTE } } : {}),
7353
7867
  ...(backfill.minted.length || backfill.error
7354
7868
  ? {
7355
7869
  storeBackfill: {
@@ -7584,8 +8098,9 @@ export function createControlPlaneApi(options) {
7584
8098
  // #2210: the store scripts first, outside the vertical upload's try, so a store failure
7585
8099
  // answers as itself and the vertical is not uploaded at all.
7586
8100
  await ensureScopeStoresOf(c.get('actor'), 'deploy', slug, manifest.scopeStores, async () => ownerTenant ?? null);
8101
+ let wire;
7587
8102
  try {
7588
- await options.deployVertical(deploymentRef, {
8103
+ wire = await deployVerticalScript(options.deployVertical, c.get('actor'), slug, deploymentRef, {
7589
8104
  entry: manifest.entry,
7590
8105
  compatibilityDate: manifest.compatibilityDate,
7591
8106
  compatibilityFlags: manifest.compatibilityFlags,
@@ -7689,6 +8204,7 @@ export function createControlPlaneApi(options) {
7689
8204
  // #1902: with the sweeper decision the push made, which no later upload re-makes.
7690
8205
  manifestJson: JSON.stringify({ ...manifest, platformSweeper: sweeper.supply }),
7691
8206
  });
8207
+ await recordWireUpload(c.var.admin, c.get('actor'), id, wire);
7692
8208
  // Same spirit as the permission-surface gate, advisory tier: when the push DECLARES
7693
8209
  // surfaces, name any surface that hostnames are still bound to but the declaration
7694
8210
  // dropped — the URL keeps resolving (routing never keys on the declaration), it just
@@ -7791,7 +8307,9 @@ export function createControlPlaneApi(options) {
7791
8307
  }
7792
8308
  // `service` repeats: one deployed unit per param, so a caller can ask for a
7793
8309
  // vertical's whole set (the dashboard's "all versions") in one query and get one
7794
- // merged stream back. Capped because each extra service is another backend query.
8310
+ // merged stream back. Capped because each extra service is another backend query, and
8311
+ // the Cloudflare reader may add a few more to fill the merged page (at most
8312
+ // `MAX_FILL_QUERIES` per read, whatever the number of services).
7795
8313
  const asked = (c.req.queries('service') ?? []).filter((s) => s.length > 0);
7796
8314
  const ownedLogServices = await ownedServices(c.get('principal'), c.get('actor'));
7797
8315
  // A confined principal reads only its own services' lines. An ask that names none
@@ -7815,7 +8333,12 @@ export function createControlPlaneApi(options) {
7815
8333
  hours: c.req.query('hours'),
7816
8334
  limit: c.req.query('limit'),
7817
8335
  });
7818
- return c.json(await options.observability.recentLogs(input));
8336
+ const { events, incomplete } = await options.observability.recentLogs(input);
8337
+ // The body stays the list every caller reads; a read that stopped short of vouching for the
8338
+ // whole window says so beside it, so a short page is never silently "nothing more".
8339
+ if (incomplete)
8340
+ c.header(LOGS_INCOMPLETE_HEADER, '1');
8341
+ return c.json(events);
7819
8342
  });
7820
8343
  // -- the TENANT grain (observability.md §3 view 4, §4.2) -----------------------
7821
8344
  //
@@ -7962,7 +8485,12 @@ export function createControlPlaneApi(options) {
7962
8485
  });
7963
8486
  // #1877: scoped to the app's own scripts — the scan and trust boundary.
7964
8487
  const services = await appServices(c.get('actor'), input.tenantId, input.scopeId, input.vertical);
7965
- return c.json(await options.observability.tenantLogs({ ...input, services }));
8488
+ const { events, incomplete } = await options.observability.tenantLogs({ ...input, services });
8489
+ // The body stays the list every caller reads; a page that may be missing lines behind a cut
8490
+ // says so beside it, as `/observability/logs` does (#2521).
8491
+ if (incomplete)
8492
+ c.header(LOGS_INCOMPLETE_HEADER, '1');
8493
+ return c.json(events);
7966
8494
  });
7967
8495
  // #1331: one app's sampled field reports, joined to the router's own request lines.
7968
8496
  app.get('/observability/tenant-field-coverage', async (c) => {
@@ -9742,7 +10270,12 @@ export function createControlPlaneApi(options) {
9742
10270
  cursor: c.req.query('cursor'),
9743
10271
  order: c.req.query('order'),
9744
10272
  });
9745
- const entries = await c.var.admin.listOpsFailures(c.get('actor'), filter);
10273
+ const entries = await c.var.admin.listOpsFailures(c.get('actor'), {
10274
+ ...filter,
10275
+ // #2540: staff's `reference` also finds a pair by the upstream handle its staff row names.
10276
+ // The text that matches is staff's, so a confined caller's search stays exact.
10277
+ ...(staffCaller(c) ? { referenceInText: true } : {}),
10278
+ });
9746
10279
  return c.json(pageOf(entries, filter.limit, (e) => e.id));
9747
10280
  });
9748
10281
  // -- sweep runs (#1232): the durable record of what a sweep pass touched -------
@@ -9946,19 +10479,19 @@ export function createControlPlaneApi(options) {
9946
10479
  * `text/plain` beacon cannot set it without a CORS preflight, 415) and refuse a request a
9947
10480
  * browser marks as from another origin (403). A request with neither header is the CLI's.
9948
10481
  */
9949
- const byAPerson = async (c) => {
10482
+ const byAPerson = async (c, words = { write: 'a store driver release write', person: 'a store driver release is published by a person' }) => {
9950
10483
  if (!/^application\/json\s*(;|$)/i.test(c.req.header('content-type') ?? '')) {
9951
- throw new ControlPlaneError(415, 'a store driver release write takes application/json');
10484
+ throw new ControlPlaneError(415, `${words.write} takes application/json`);
9952
10485
  }
9953
10486
  const origin = c.req.header('origin');
9954
10487
  const site = c.req.header('sec-fetch-site');
9955
10488
  if ((origin && origin !== new URL(c.req.url).origin) || (site && site !== 'same-origin' && site !== 'none')) {
9956
- throw new ControlPlaneError(403, 'a store driver release write is not taken from another origin');
10489
+ throw new ControlPlaneError(403, `${words.write} is not taken from another origin`);
9957
10490
  }
9958
10491
  const actor = c.get('actor');
9959
10492
  const person = options.humanStaffActor ? await options.humanStaffActor(c.req.raw) : null;
9960
10493
  if (!person || person !== actor) {
9961
- throw new ControlPlaneError(403, 'a store driver release is published by a person: this takes a staff sign-in, not a service token');
10494
+ throw new ControlPlaneError(403, `${words.person}: this takes a staff sign-in, not a service token`);
9962
10495
  }
9963
10496
  return actor;
9964
10497
  };
@@ -10122,8 +10655,86 @@ export function createControlPlaneApi(options) {
10122
10655
  const again = ran.outcome === 'paused' || ran.outcome === 'held' || ran.outcome === 'busy';
10123
10656
  return c.json({ pass: ran, rebuild }, again ? 202 : 200);
10124
10657
  });
10658
+ // RFC wire-signing follow-on 4: one pass of the fleet walk, which brings every script's key lists,
10659
+ // audience and state to what this control plane gives now (`runWireWalk`). A person's act; a dry run
10660
+ // unless the body says `apply: true`; at most `limit` scripts, from `cursor`, answering where the next
10661
+ // pass starts. `release` names held scripts a person has looked at. Audited as `walkWireAuth`
10662
+ // however the pass ends. Not to be run while a push or promote of a walked vertical is in flight.
10663
+ app.post('/wire-auth/walk', async (c) => {
10664
+ const actor = await byAPerson(c, { write: 'a wire walk', person: 'the wire walk is run by a person' });
10665
+ const api = options.wireSettings;
10666
+ if (!api || !c.var.admin.listWireAuth || !c.var.admin.recordWireAuth || !c.var.admin.recordWireWalk) {
10667
+ throw new ControlPlaneError(501, 'this control plane does not walk the fleet’s wire settings');
10668
+ }
10669
+ const body = z
10670
+ .object({
10671
+ apply: z.boolean().optional(),
10672
+ confirm: z.boolean().optional(),
10673
+ cursor: z.string().min(1).max(512).optional(),
10674
+ limit: z.number().int().min(1).max(WIRE_WALK_LIMIT_MAX).optional(),
10675
+ release: z.array(z.string().min(1).max(255)).max(WIRE_WALK_RELEASE_MAX).optional(),
10676
+ })
10677
+ .strict()
10678
+ .parse(await readCappedJson(c.req.raw, WIRE_WALK_REQUEST_MAX_BYTES));
10679
+ const cursor = body.cursor === undefined ? null : parseWireWalkCursor(body.cursor);
10680
+ if (body.cursor !== undefined && cursor === null)
10681
+ throw new ControlPlaneError(422, 'the cursor is not one a pass answered');
10682
+ const apply = body.apply === true;
10683
+ const confirm = body.confirm === true;
10684
+ // Refused settings give every script nothing, which an applied pass would write as a fleet-wide
10685
+ // withdrawal: the pass is refused before it walks anything, dry or applied, and audited as such.
10686
+ const refused = await wireSettingsRefusal(options.wireUpload);
10687
+ if (refused) {
10688
+ await c.var.admin.recordWireWalk(actor, {
10689
+ apply, confirm, from: body.cursor ?? null, next: null, walked: 0,
10690
+ updated: [], held: [], released: [], skipped: [], failed: [],
10691
+ refused: `${refused.withheld}: ${refused.reason}`,
10692
+ });
10693
+ throw new ControlPlaneError(409, `the wire walk is refused: ${refused.withheld}: ${refused.reason}; fix the control plane's setting, then walk`);
10694
+ }
10695
+ // The pass puts each script's result here as it goes, so a pass that ends in an error is still
10696
+ // audited with what it did.
10697
+ const walked = [];
10698
+ let pass = null;
10699
+ let failedWith;
10700
+ try {
10701
+ pass = await runWireWalk({
10702
+ admin: c.var.admin,
10703
+ actor,
10704
+ settings: options.wireUpload,
10705
+ api,
10706
+ ...(options.fetchVerticalModules ? { fetchModules: options.fetchVerticalModules } : {}),
10707
+ fail: ({ vertical, version, stage, message }) => recordFailure({ actor, operation: 'wire-walk', stage, vertical, ...(version ? { version } : {}), message }),
10708
+ }, { apply, confirm, cursor, limit: body.limit ?? WIRE_WALK_LIMIT_DEFAULT, release: body.release ?? [], sink: walked });
10709
+ }
10710
+ catch (error) {
10711
+ failedWith = 'the pass stopped on a directory read it could not make; the scripts it walked before are listed';
10712
+ throw error;
10713
+ }
10714
+ finally {
10715
+ // Written however the pass ended: a person reached the fleet, or tried to.
10716
+ const named = (pick) => walked.filter(pick).map((s) => s.deploymentRef);
10717
+ const why = (action) => walked.filter((s) => s.action === action).map((s) => ({ deploymentRef: s.deploymentRef, reason: s.reason ?? action }));
10718
+ await c.var.admin.recordWireWalk(actor, {
10719
+ apply,
10720
+ confirm,
10721
+ from: body.cursor ?? null,
10722
+ next: pass?.next ?? null,
10723
+ walked: walked.length,
10724
+ updated: named((s) => s.action === 'updated'),
10725
+ held: named((s) => s.held === true),
10726
+ released: named((s) => s.released === true),
10727
+ skipped: why('skipped'),
10728
+ failed: why('failed'),
10729
+ ...(failedWith ? { error: failedWith } : {}),
10730
+ });
10731
+ }
10732
+ return c.json(pass);
10733
+ });
10125
10734
  return app;
10126
10735
  }
10736
+ /** The most a wire walk's request body may be: a cursor and up to `WIRE_WALK_RELEASE_MAX` script names. */
10737
+ const WIRE_WALK_REQUEST_MAX_BYTES = 64 * 1024;
10127
10738
  /**
10128
10739
  * The most a release upload's body may be: the decoded cap, as base64 (4 bytes per 3), plus room
10129
10740
  * for the JSON around it. A bigger body is refused (413) before it is read whole.