@substrat-run/control-plane-api 0.147.0 → 0.149.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 (80) hide show
  1. package/dist/api.d.ts +67 -4
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +1068 -418
  4. package/dist/api.js.map +1 -1
  5. package/dist/attachment-sweep.d.ts +29 -0
  6. package/dist/attachment-sweep.d.ts.map +1 -0
  7. package/dist/attachment-sweep.js +66 -0
  8. package/dist/attachment-sweep.js.map +1 -0
  9. package/dist/browser.d.ts +3 -1
  10. package/dist/browser.d.ts.map +1 -1
  11. package/dist/browser.js +3 -0
  12. package/dist/browser.js.map +1 -1
  13. package/dist/cf-observability.d.ts.map +1 -1
  14. package/dist/cf-observability.js +3 -1
  15. package/dist/cf-observability.js.map +1 -1
  16. package/dist/deploy.d.ts +26 -11
  17. package/dist/deploy.d.ts.map +1 -1
  18. package/dist/deploy.js +33 -18
  19. package/dist/deploy.js.map +1 -1
  20. package/dist/errors.d.ts +19 -1
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js +18 -4
  23. package/dist/errors.js.map +1 -1
  24. package/dist/index.d.ts +15 -5
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +10 -4
  27. package/dist/index.js.map +1 -1
  28. package/dist/lake-gateway.d.ts +54 -0
  29. package/dist/lake-gateway.d.ts.map +1 -0
  30. package/dist/lake-gateway.js +158 -0
  31. package/dist/lake-gateway.js.map +1 -0
  32. package/dist/observability.d.ts +28 -0
  33. package/dist/observability.d.ts.map +1 -1
  34. package/dist/observability.js +35 -0
  35. package/dist/observability.js.map +1 -1
  36. package/dist/platform-entry.generated.d.ts +1 -1
  37. package/dist/platform-entry.generated.d.ts.map +1 -1
  38. package/dist/platform-entry.generated.js +2 -2
  39. package/dist/platform-entry.generated.js.map +1 -1
  40. package/dist/platform-scope-stores.d.ts +65 -12
  41. package/dist/platform-scope-stores.d.ts.map +1 -1
  42. package/dist/platform-scope-stores.js +121 -20
  43. package/dist/platform-scope-stores.js.map +1 -1
  44. package/dist/r2-sql-engine.d.ts +50 -0
  45. package/dist/r2-sql-engine.d.ts.map +1 -0
  46. package/dist/r2-sql-engine.js +126 -0
  47. package/dist/r2-sql-engine.js.map +1 -0
  48. package/dist/scope-copy-cleanup.d.ts +13 -1
  49. package/dist/scope-copy-cleanup.d.ts.map +1 -1
  50. package/dist/scope-copy-cleanup.js +14 -3
  51. package/dist/scope-copy-cleanup.js.map +1 -1
  52. package/dist/staff-client.d.ts +10 -2
  53. package/dist/staff-client.d.ts.map +1 -1
  54. package/dist/staff-client.js +6 -1
  55. package/dist/staff-client.js.map +1 -1
  56. package/dist/store-entry.d.ts +90 -0
  57. package/dist/store-entry.d.ts.map +1 -0
  58. package/dist/store-entry.generated.d.ts +60 -0
  59. package/dist/store-entry.generated.d.ts.map +1 -0
  60. package/dist/store-entry.generated.js +62 -0
  61. package/dist/store-entry.generated.js.map +1 -0
  62. package/dist/store-entry.js +178 -0
  63. package/dist/store-entry.js.map +1 -0
  64. package/dist/store-rebuild.d.ts +142 -0
  65. package/dist/store-rebuild.d.ts.map +1 -0
  66. package/dist/store-rebuild.js +223 -0
  67. package/dist/store-rebuild.js.map +1 -0
  68. package/dist/store-releases.d.ts +158 -0
  69. package/dist/store-releases.d.ts.map +1 -0
  70. package/dist/store-releases.js +332 -0
  71. package/dist/store-releases.js.map +1 -0
  72. package/dist/store-script.d.ts +98 -0
  73. package/dist/store-script.d.ts.map +1 -0
  74. package/dist/store-script.js +188 -0
  75. package/dist/store-script.js.map +1 -0
  76. package/dist/vertical-client.d.ts +33 -2
  77. package/dist/vertical-client.d.ts.map +1 -1
  78. package/dist/vertical-client.js +58 -3
  79. package/dist/vertical-client.js.map +1 -1
  80. package/package.json +7 -7
package/dist/api.js CHANGED
@@ -1,15 +1,18 @@
1
+ import { PREVIEW_CONNECTIONS_REFUSAL } from './connection-relay.js';
1
2
  import { resolveObservabilityWindow } from './observability-window.js';
2
3
  import { Hono } from 'hono';
3
- import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ownerTransferInput, memberInviteInput, memberRoleInput, ASSET_PART_PREFIX, assetHash, channelName, connectLinkId, connectionActivity, connectionActivitySource, connectionCredential, connectionCertificate, connectionFilter, connectionHealthState, connectionProvider, connectorDispatchKind, PROVISION_SIBLING_KIND, ARCHIVE_SCOPE_KIND, PROVISION_TENANT_KIND, SET_ENTITLEMENTS_KIND, MODEL_USAGE_KIND, SWEEP_RUNS_KIND, toConnectionHealthEntry, CONNECTION_EXPIRY_WARNING_DAYS, CONNECTION_STALE_DAYS, connectionProbe, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, errorCode, issueStatus, issueStatusInput, FINDINGS_LIMIT_DEFAULT, FINDINGS_LIMIT_MAX, findingCursorOf, findingKind, findingRuleInput, findingStatus, findingStatusInput, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, impersonationStamp, promotionAcknowledgement, bindAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, entityHistoryInput, eventFacetInput, eventCauseInput, eventEffectsInput, invocationEventsInput, deadLettersInput, lifecycleFlowInput, operationSeriesCount, operationSeriesInput, delegatedReadParams, createOrgInput, roleKey as roleKeySchema, peerSwitch, verticalSlug, systemSwitch, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, denialGroupBy, registerVerticalInput, platformScriptCollision, platformScriptRefusal, PLATFORM_SCRIPT_NAMES, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, redrainEventsInput, importCursorMove, importCursorAcknowledgementMissing, REDRAIN_BATCH, migrationsOnTop, errorCodeOf, capabilityFilterQuery, capabilityId as capabilityIdSchema, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, } from '@substrat-run/contracts';
4
- import { SCOPE_COPY_LEASE_MS, copyRestoreFence, replayStoreErasures } from '@substrat-run/kernel';
4
+ 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, } from '@substrat-run/contracts';
5
+ import { SCOPE_COPY_LEASE_MS, copyRestoreFence, publishedMigrationDigest, replayStoreErasures } from '@substrat-run/kernel';
5
6
  import { attributeFailure } from './failure-attribution.js';
6
7
  import { auditedChange, withAuditedOutcomes } from './audited-change.js';
7
- import { BIND_EXPORT_BREAK_REFUSAL, CARRIED_AWAY_KEY, bindExportBreakRefusal, carriedAwayDump, dumpMetaValue, metaValueIn, crossVerticalHealth, exportBreakRefusal, isBindExportBreakRefusal, isExportBreakRefusal, isPrimaryScope, migrationProgress, ulid, auditWarningOf, } from '@substrat-run/kernel';
8
+ 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';
8
9
  import { TENANT_HEADER, confinedTenant } from './auth.js';
9
10
  import { mintTenantToken } from './tenant-token.js';
10
11
  import { connectionGrantsForScope } from './vertical-client.js';
11
- import { scopeStoresOf } from './platform-scope-stores.js';
12
- import { assertNoUnreachableScopeCopies, listAllScopeScriptCopies, reapScopeScriptCopies, recordStrandedStorage } from './scope-copy-cleanup.js';
12
+ import { runningVersionOf, runningVersionStoresOf, scopeStoreReachOf, scopeStoresOf, unrouteScope } from './platform-scope-stores.js';
13
+ import { STORE_REBUILD_REQUEST_SUBREQUESTS, runStoreFleetRebuild, storeRebuildBudget, storeRebuildStatus } from './store-rebuild.js';
14
+ import { PUSH_HOLD_TTL_MS, runStoreDriverRollout, storeDriverRelease, storedReleaseLoader } from './store-releases.js';
15
+ import { assertNoUnreachableScopeCopies, claimScopeScriptReap, drainScopeScriptCopies, listAllScopeScriptCopies, recordStrandedStorage } from './scope-copy-cleanup.js';
13
16
  import { BACKFILL_PAGE_MAX, backfillScopeScriptCopies } from './scope-copy-backfill.js';
14
17
  import { oidcCallbackUrl, retireClientsOfReapedScope, wirePreviewAuth } from './preview-auth.js';
15
18
  import { scopeDeployment, versionReachedAt } from './scope-deployment.js';
@@ -18,11 +21,11 @@ import { reconcileOutcomeOf, reconcileThenReassert, reviewedEntityGrants, switch
18
21
  import { ConnectionRelayError, relayConnectionUpsert } from './connection-relay.js';
19
22
  import { ControlPlaneError } from '@substrat-run/control-plane-client';
20
23
  import { provisionSiblingScope } from './platform-drain.js';
21
- import { mapError } from './errors.js';
24
+ import { NON_PROBLEM_REFUSAL, coded, mapError, relayed } from './errors.js';
22
25
  import { maskDump, maskRecords } from './mask.js';
23
26
  import { createPseudonymizer } from './pseudonymize.js';
24
27
  import { openDump, sealDump } from './seal.js';
25
- import { assertSandboxContract, verticalUploadBindings, deployManifest, storedDeployManifest, pushedPermissionRegistry, deploymentRefFor, stableDeploymentRefFor, nextMigrationTag, upstreamStatusOf, } from './deploy.js';
28
+ import { assertSandboxContract, verticalUploadBindings, migrationsDigestBindings, deployManifest, storedDeployManifest, pushedPermissionRegistry, deploymentRefFor, stableDeploymentRefFor, nextMigrationTag, upstreamStatusOf, } from './deploy.js';
26
29
  /**
27
30
  * Answer with a problem document (#113 phase 4).
28
31
  *
@@ -30,12 +33,30 @@ import { assertSandboxContract, verticalUploadBindings, deployManifest, storedDe
30
33
  * labelled `application/json` is not an RFC 9457 problem however well-shaped its body is,
31
34
  * and `/openapi.json` has documented `application/problem+json` on every error response
32
35
  * since phase 1 — this is the half that makes the document true.
36
+ *
37
+ * `carried` is what a route answered beside `error` before it answered a problem document
38
+ * (a connector's `probe`, a preview's `operationId`, …), kept verbatim so no client loses a
39
+ * member. RFC 9457 §3.2 permits extension members; these are not declared per code in
40
+ * `PROBLEM_EXTENSIONS` yet, which is why they are spelled out at each route rather than
41
+ * passed through by a mapper. Spread last on purpose: where a route's own `detail` meant the
42
+ * downstream's reason, it still does.
33
43
  */
34
- function problem(c, answer) {
35
- return c.body(JSON.stringify(answer.body), answer.status, {
44
+ function problem(c, answer, carried) {
45
+ return c.body(JSON.stringify(carried ? { ...answer.body, ...carried } : answer.body), answer.status, {
36
46
  'content-type': PROBLEM_CONTENT_TYPE,
37
47
  });
38
48
  }
49
+ /** A refusal this route decided, by its code (#113): the status follows from the catalog. */
50
+ function refuse(c, code, detail, carried) {
51
+ return problem(c, coded(code, detail), carried);
52
+ }
53
+ /**
54
+ * A refusal answered by status alone, as `about:blank`: one relayed from downstream (a
55
+ * `ControlPlaneError`'s status), or one the closed taxonomy has no code for (501, 422, 502).
56
+ */
57
+ function refuseStatus(c, status, detail, carried) {
58
+ return problem(c, relayed(status, detail), carried);
59
+ }
39
60
  import { backfillDeclaredStores, blobStoreBindings, collectBlobStoreHandles, collectTenantStoreHandles, missingStoresForTenant, tenantStoreBindings, } from './tenant-stores.js';
40
61
  import { mintPushToken, pushActorFor } from './push-token.js';
41
62
  import { TENANT_SERIES_SCOPE_CAP, REQUEST_FACET_KEYS } from './observability.js';
@@ -44,6 +65,8 @@ import { namespacesForScript } from './do-namespaces.js';
44
65
  import { backupDirectoryIfDue } from './directory-backup.js';
45
66
  import { deployedDoClasses, platformSweeperDecision } from './platform-entry.js';
46
67
  import { STORAGE_PAGE_DEFAULT, STORAGE_PAGE_MAX, readStoragePage } from './storage-meter.js';
68
+ import { queryLakeEvents } from './lake-gateway.js';
69
+ import { LakeQueryError } from './r2-sql-engine.js';
47
70
  import { isCustomHostname, validateBindableHostname, } from './custom-hostnames.js';
48
71
  /**
49
72
  * Response headers carrying the CLI version advisory (`ControlPlaneApiOptions.cliAdvisory`).
@@ -104,9 +127,9 @@ class CarrySourceChanged extends ControlPlaneError {
104
127
  this.attempts = attempts;
105
128
  }
106
129
  }
107
- /** The body a route answers an `ExportBreakRefused` with: the sentence and the listing. */
108
- function exportBreakBody(e) {
109
- return { error: e.message, ...(e instanceof ExportBreakRefused ? { exportBreaks: { affected: e.breaks } } : {}) };
130
+ /** What a route answers beside the sentence of an `ExportBreakRefused`: the listing. */
131
+ function exportBreaksOf(e) {
132
+ return e instanceof ExportBreakRefused ? { exportBreaks: { affected: e.breaks } } : {};
110
133
  }
111
134
  /**
112
135
  * A delegated facet answer, normalized — and refused when the vertical that produced it
@@ -275,6 +298,8 @@ const rebindScopeVerticalBody = z.object({
275
298
  // version carry the SAME migration digest — or the operator acknowledges having
276
299
  // read both migration surfaces. Same discipline as a promote's `--ack-migrations`.
277
300
  ackMigrations: z.boolean().optional(),
301
+ // (#2347: the acknowledgement names the review it answers, `acknowledge.review`, as a
302
+ // promote's does; an `ackMigrations` without one is refused where the digests differ.)
278
303
  // Rebind the DIRECTORY only — no export/restore. For a scope whose source script
279
304
  // predates the `/internal/export` surface (#236) and so cannot be dumped at all.
280
305
  // The data is not deleted: it stays on the source script, which remains the
@@ -282,7 +307,8 @@ const rebindScopeVerticalBody = z.object({
282
307
  // on the target afterwards (`/verticals/:slug/instances` is idempotent, K-31).
283
308
  abandonData: z.boolean().optional(),
284
309
  // #1756: within one lineage the rebind is an adopt, refused when it breaks an app in the tenant.
285
- acknowledge: bindAcknowledgement.optional(),
310
+ // #2347: `review`, the rebind review `ackMigrations` answers (`GET …/rebind-review`).
311
+ acknowledge: rebindAcknowledgement.optional(),
286
312
  });
287
313
  // #1756: an adopt moves the scope onto what its vertical serves, and is refused when that breaks
288
314
  // an app in the tenant. The body is optional, as it always was.
@@ -672,6 +698,8 @@ const TENANT_ROUTES = [
672
698
  { method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/query$/, pin: 'path' },
673
699
  { method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/reap$/, pin: 'path' },
674
700
  { method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/rebind-vertical$/, pin: 'path' },
701
+ // #2347: the review a rebind's migration acknowledgement answers; visible exactly as the rebind is.
702
+ { method: 'GET', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/rebind-review$/, pin: 'path' },
675
703
  { method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/restore$/, pin: 'path' },
676
704
  { method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/rewind$/, pin: 'path' },
677
705
  { method: 'POST', re: /\/tenants\/[^/]+\/scopes\/[^/]+\/snapshots$/, pin: 'path' },
@@ -782,11 +810,15 @@ function messageOfThrow(err) {
782
810
  /** What a 5xx answered with no error object said: the JSON body's `error` (and `detail`), else the status. */
783
811
  async function answeredMessage(res) {
784
812
  const body = (await res.clone().json().catch(() => null));
785
- const error = typeof body?.['error'] === 'string' ? body['error'] : typeof body?.['title'] === 'string' ? body['title'] : null;
786
- const detail = typeof body?.['detail'] === 'string' ? body['detail'] : null;
787
- if (error && detail)
813
+ const str = (k) => (typeof body?.[k] === 'string' && body[k] !== '' ? body[k] : null);
814
+ const error = str('error');
815
+ const detail = str('detail');
816
+ // A headline beside a reason (`deploy upload failed` + the upstream's sentence) reads as
817
+ // both. A problem document's `error` is only the deprecated duplicate of its `detail`
818
+ // (#113), so it adds nothing, and once it is gone `detail` alone still names the occurrence.
819
+ if (error && detail && error !== detail)
788
820
  return `${error}: ${detail}`;
789
- return error ?? detail ?? `HTTP ${res.status}`;
821
+ return detail ?? error ?? str('title') ?? `HTTP ${res.status}`;
790
822
  }
791
823
  /**
792
824
  * The audited HTTP surface over `HostAdmin` (control-plane.md §4.5).
@@ -819,33 +851,62 @@ export function createControlPlaneApi(options) {
819
851
  const { host, authenticate, authenticateBuilder, authenticateTenantService } = options;
820
852
  const copyLeaseMs = options.copyLeaseMs ?? SCOPE_COPY_LEASE_MS;
821
853
  /** The stores of the version the scope's deployment runs (`scopeStoresOf`, #2207). */
822
- const manifestStores = (c, scope) => scopeStoresOf(c.var.admin, c.get('actor'), scope, options.storeKinds);
854
+ const manifestStores = (c, scope, running) => scopeStoresOf(c.var.admin, c.get('actor'), scope, options.storeDrivers, running);
823
855
  /**
824
856
  * The platform's path to a scope's stores, or nothing: no caller is a co-located host whose
825
857
  * own export, restore, wipe and erasure carry its stores, and a scope that declares none has
826
858
  * none to reach. A hosted control plane always passes a caller (`unreachablePlatformScopeStores`
827
859
  * when it has no route), so a declared store is never skipped there.
828
860
  */
829
- const platformStoresOf = async (c, scope) => {
861
+ const platformStoresOf = async (c, scope, running) => {
830
862
  const caller = options.platformScopeStores;
831
863
  if (!caller || !scope.vertical)
832
864
  return undefined;
833
- const stores = await manifestStores(c, scope);
865
+ const stores = await manifestStores(c, scope, running);
834
866
  return stores.length > 0 ? { caller, vertical: scope.vertical, stores } : undefined;
835
867
  };
836
- /** `substrat.wipe` on each declared store, before the scope's own bytes go. */
837
- const wipePlatformStores = async (c, scope) => {
868
+ /**
869
+ * `substrat.wipe` on each declared store, resolved now and called later: a reap resolves its
870
+ * stores, which can refuse (503), before it unroutes the scope, and wipes after (#2285).
871
+ */
872
+ const platformStoresWipe = async (c, scope) => {
838
873
  const reach = await platformStoresOf(c, scope);
839
- if (!reach)
840
- return;
841
- await reach.caller.wipeStores({ tenantId: scope.tenantId, scopeId: scope.id, vertical: reach.vertical, stores: reach.stores });
874
+ return async () => {
875
+ if (!reach)
876
+ return;
877
+ await reach.caller.wipeStores({ tenantId: scope.tenantId, scopeId: scope.id, vertical: reach.vertical, stores: reach.stores });
878
+ };
842
879
  };
843
- /** `substrat.export` on each declared store, for a backup, a full pull or a fork. */
880
+ /** `substrat.wipe` on each declared store, before the scope's own bytes go. */
881
+ const wipePlatformStores = async (c, scope) => (await platformStoresWipe(c, scope))();
882
+ /**
883
+ * `substrat.export` on each store the lifecycle reaches, for a backup, a full pull or a fork.
884
+ *
885
+ * A dump is only worth taking if it restores, and a restore imports only what the destination's
886
+ * version declares (L8). So a binding only an earlier version declared (#2286) is left out of the
887
+ * dump when its export is empty, which is what an object that was never written, or one already
888
+ * wiped, answers. One that still holds rows refuses the export (409), naming the binding and the
889
+ * way out, rather than produce a dump its own scope could not restore. Wipe, reap and erasure
890
+ * never export, so they reach such a store regardless.
891
+ */
844
892
  const exportPlatformStores = async (c, scope) => {
845
- const reach = await platformStoresOf(c, scope);
846
- if (!reach)
893
+ const caller = options.platformScopeStores;
894
+ if (!caller || !scope.vertical)
847
895
  return undefined;
848
- return reach.caller.exportStores({ tenantId: scope.tenantId, scopeId: scope.id, vertical: reach.vertical, stores: reach.stores });
896
+ const { running, all } = await scopeStoreReachOf(c.var.admin, c.get('actor'), scope, options.storeDrivers);
897
+ if (all.length === 0)
898
+ return undefined;
899
+ const exported = await caller.exportStores({ tenantId: scope.tenantId, scopeId: scope.id, vertical: scope.vertical, stores: all });
900
+ const current = new Set(running.map((store) => store.binding));
901
+ const dropped = exported.filter((store) => !current.has(store.binding));
902
+ const held = dropped.find((store) => store.tables.some((table) => table.rows.length > 0));
903
+ if (held) {
904
+ throw new ControlPlaneError(409, `scope ${scope.id}'s store '${held.binding}' was declared by an earlier version of '${scope.vertical}' and ` +
905
+ `still holds data, and the version the scope runs does not declare it, so a dump of it could not be ` +
906
+ `restored. Declare '${held.binding}' as a '${held.driver}' again in a version the scope runs, then export; ` +
907
+ `or reap the scope without a backup, which wipes it`);
908
+ }
909
+ return exported.filter((store) => current.has(store.binding));
849
910
  };
850
911
  /**
851
912
  * `substrat.erase` on the destination's stores for each subject tombstoned in the destination
@@ -893,7 +954,7 @@ export function createControlPlaneApi(options) {
893
954
  };
894
955
  /**
895
956
  * A dump's stores, checked against what the destination's version declares (#2207), before
896
- * any write: a binding it does not declare, or one of another kind, would be imported into an
957
+ * any write: a binding it does not declare, or one of another driver, would be imported into an
897
958
  * object nothing reads, or into one of the wrong shape.
898
959
  */
899
960
  const refuseUndeclaredDumpStores = async (c, scope, stores) => {
@@ -901,18 +962,48 @@ export function createControlPlaneApi(options) {
901
962
  return;
902
963
  if (!scope.vertical)
903
964
  throw new ControlPlaneError(422, 'restore refused: the dump names stores and the scope names no vertical');
904
- const declared = new Map((await manifestStores(c, scope)).map((d) => [d.binding, d.kind]));
965
+ // The destination's running version only (L8): a binding only an earlier version declared
966
+ // would be imported into an object nothing in the code the scope runs reads.
967
+ const declared = new Map((await runningVersionStoresOf(c.var.admin, c.get('actor'), scope, options.storeDrivers))
968
+ .map((d) => [d.binding, d.driver]));
905
969
  for (const store of stores) {
906
- const kind = declared.get(store.binding);
907
- if (kind === undefined) {
970
+ const driver = declared.get(store.binding);
971
+ if (driver === undefined) {
908
972
  throw new ControlPlaneError(422, `restore refused: the dump names store '${store.binding}', which this scope's version does not declare`);
909
973
  }
910
- if (kind !== store.kind) {
911
- throw new ControlPlaneError(422, `restore refused: the dump's store '${store.binding}' is a '${store.kind}', and this scope's version declares a '${kind}'`);
974
+ if (driver !== store.driver) {
975
+ throw new ControlPlaneError(422, `restore refused: the dump's store '${store.binding}' is a '${store.driver}', and this scope's version declares a '${driver}'`);
912
976
  }
913
977
  }
914
978
  };
915
979
  const app = new Hono();
980
+ // Every refusal is a problem document (#113): the tripwire. A route answers a failure
981
+ // through `refuse`/`refuseStatus`/`problem`, and anything else — a hand-rolled
982
+ // `c.json({ error }, 4xx)`, a sub-app's text 404 — is logged here as
983
+ // `control-plane.non-problem-refusal` with the method, the route PATTERN (never the raw
984
+ // path, which carries tenant and scope ids) and the status. It reads one header and never
985
+ // the body, so it costs nothing on the success path and discloses nothing on the failure
986
+ // one. Two mechanisms hold the rule, and they cover different things: this package's test
987
+ // setup fails any test in which the tripwire fires, which covers every refusal a test
988
+ // REACHES; `test/problem-source.test.ts` scans this file, reached or not, and refuses any
989
+ // response it builds with a failure status outside the problem helpers — and any construct
990
+ // whose status it could not judge (`c.status(`, `c.newResponse(`, `c.html(`, a generic
991
+ // `c.json<T>(`, a `Response` init it cannot read). In production the tripwire is the line
992
+ // an operator searches the worker's logs for, should a client report a refusal it could
993
+ // not parse.
994
+ //
995
+ // Registered first, so it sees the answer every other layer settled on, `onError`'s included.
996
+ app.use('*', async (c, next) => {
997
+ await next();
998
+ if (c.res.status >= 400 && !(c.res.headers.get('content-type') ?? '').startsWith(PROBLEM_CONTENT_TYPE)) {
999
+ console.warn(NON_PROBLEM_REFUSAL, { method: c.req.method, route: c.req.routePath, status: c.res.status });
1000
+ }
1001
+ });
1002
+ // A request no route matches is a refusal too, and Hono's default answers it as text. This
1003
+ // answers only where this app is the top-level one (the node server, a self-host): Hono never
1004
+ // calls a MOUNTED sub-app's `notFound`, so under the hosted worker's `app.route('/api', …)`
1005
+ // the worker's own fallback still answers an unmatched path.
1006
+ app.notFound((c) => refuse(c, 'not_found', 'no such route'));
916
1007
  // The 5xx backstop: every 5xx this API answers lands an ops-failure row, however it was
917
1008
  // answered. A 5xx is the PLATFORM failing (an unmapped throw, or a downstream vertical's own
918
1009
  // 5xx passing through, #559), so the console must be able to list it and a `reference = <id>`
@@ -920,7 +1011,7 @@ export function createControlPlaneApi(options) {
920
1011
  // that catch a ControlPlaneError and answer it themselves: a preview re-push whose bind failed
921
1012
  // after a clean carry answered 502 with a reference and left no row behind.
922
1013
  //
923
- // Registered first, so it wraps every other middleware and route. 501 stays out: an honest
1014
+ // Registered ahead of every middleware but the tripwire, so it wraps every route. 501 stays out: an honest
924
1015
  // not-implemented is a capability statement, not a failure. 4xx stay out too: they are
925
1016
  // refusals the caller can already read. Actor is unset only when the request failed before
926
1017
  // authentication, where nothing worth recording refuses. A throw a step already recorded,
@@ -1013,7 +1104,7 @@ export function createControlPlaneApi(options) {
1013
1104
  }
1014
1105
  }
1015
1106
  if (!principal)
1016
- return c.json({ error: 'unauthenticated' }, 401);
1107
+ return refuse(c, 'unauthenticated', 'unauthenticated');
1017
1108
  c.set('principal', principal);
1018
1109
  c.set('actor', principal.actor);
1019
1110
  // #977: a tenant credential minted for a person writes through a view that names
@@ -1127,7 +1218,7 @@ export function createControlPlaneApi(options) {
1127
1218
  if (c.get('principal').kind === 'builder') {
1128
1219
  const allowed = BUILDER_ROUTES.some((r) => r.method === c.req.method && r.re.test(c.req.path));
1129
1220
  if (!allowed)
1130
- return c.json({ error: 'forbidden' }, 403);
1221
+ return refuse(c, 'forbidden', 'forbidden');
1131
1222
  }
1132
1223
  await next();
1133
1224
  });
@@ -1137,25 +1228,25 @@ export function createControlPlaneApi(options) {
1137
1228
  return next();
1138
1229
  const pin = tenantCredentialPin(c.req.method, c.req.path);
1139
1230
  if (!pin)
1140
- return c.json({ error: 'forbidden' }, 403);
1231
+ return refuse(c, 'forbidden', 'forbidden');
1141
1232
  // The sweep: anything naming a tenant must name THIS one. `x-substrat-tenant` is
1142
1233
  // included, which is what stops the pre-#977 shape — a fleet credential plus a
1143
1234
  // caller-asserted tenant header — from surviving as a way to ask for another.
1144
1235
  if (namedTenants(c).some((t) => t !== p.tenantId)) {
1145
- return c.json({ error: 'forbidden: outside this credential’s tenant' }, 403);
1236
+ return refuse(c, 'forbidden', 'forbidden: outside this credential’s tenant');
1146
1237
  }
1147
1238
  // The route's own pin must be PRESENT, not merely consistent. A missing filter is
1148
1239
  // what makes these routes answer fleet-wide, so absence is the refusal.
1149
1240
  if (pin === 'path' && pathTenant(c.req.path) !== p.tenantId) {
1150
- return c.json({ error: 'forbidden: outside this credential’s tenant' }, 403);
1241
+ return refuse(c, 'forbidden', 'forbidden: outside this credential’s tenant');
1151
1242
  }
1152
1243
  if (pin === 'query' && c.req.query('tenantId') !== p.tenantId) {
1153
- return c.json({ error: 'forbidden: this credential must name its own tenant' }, 403);
1244
+ return refuse(c, 'forbidden', 'forbidden: this credential must name its own tenant');
1154
1245
  }
1155
1246
  if (pin === 'catalog') {
1156
1247
  const named = c.req.query('ownerTenant') ?? c.req.query('visibleTo');
1157
1248
  if (named !== p.tenantId) {
1158
- return c.json({ error: 'forbidden: this credential must name its own tenant' }, 403);
1249
+ return refuse(c, 'forbidden', 'forbidden: this credential must name its own tenant');
1159
1250
  }
1160
1251
  }
1161
1252
  if (pin === 'body') {
@@ -1165,7 +1256,7 @@ export function createControlPlaneApi(options) {
1165
1256
  const body = (await c.req.json().catch(() => null));
1166
1257
  const named = body && typeof body === 'object' ? (body['tenantId'] ?? body['id']) : undefined;
1167
1258
  if (named !== p.tenantId) {
1168
- return c.json({ error: 'forbidden: this credential must name its own tenant' }, 403);
1259
+ return refuse(c, 'forbidden', 'forbidden: this credential must name its own tenant');
1169
1260
  }
1170
1261
  }
1171
1262
  await next();
@@ -1235,6 +1326,8 @@ export function createControlPlaneApi(options) {
1235
1326
  c.set('failure', e);
1236
1327
  return e.status;
1237
1328
  };
1329
+ /** A caught `ControlPlaneError` answered as itself — its status and sentence, `about:blank` — and marked for the backstop. */
1330
+ const relayRefusal = (c, e, carried) => refuseStatus(c, failedWith(c, e), e.message, carried);
1238
1331
  // Rides out a transient downstream window: the install path's binding-attach →
1239
1332
  // script-settings propagation race (#424 case 2), and a one-shot DO storage blip
1240
1333
  // during an export→restore or snapshot copy (#559 (2)). Retrying is cheap at THESE
@@ -1272,9 +1365,9 @@ export function createControlPlaneApi(options) {
1272
1365
  return problem(c, { status: 400, body: toProblem(err, c.req.path) });
1273
1366
  }
1274
1367
  if (err instanceof ExportBreakRefused)
1275
- return c.json(exportBreakBody(err), 409);
1368
+ return refuse(c, 'conflict', err.message, exportBreaksOf(err));
1276
1369
  if (err instanceof CarrySourceChanged) {
1277
- return c.json({ error: err.message, carrySourceChanged: { scopeId: err.scopeId, from: err.from, to: err.to, attempts: err.attempts } }, 409);
1370
+ return refuse(c, 'conflict', err.message, { carrySourceChanged: { scopeId: err.scopeId, from: err.from, to: err.to, attempts: err.attempts } });
1278
1371
  }
1279
1372
  const { status, body } = mapError(err);
1280
1373
  // The 5xx backstop (registered first, above) records it, with this throw as the cause.
@@ -1318,7 +1411,7 @@ export function createControlPlaneApi(options) {
1318
1411
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1319
1412
  const tenant = await c.var.admin.getTenant(c.get('actor'), tenantId);
1320
1413
  if (!tenant)
1321
- return c.json({ error: `unknown tenant: ${tenantId}` }, 404);
1414
+ return refuse(c, 'not_found', `unknown tenant: ${tenantId}`);
1322
1415
  return c.json(tenant);
1323
1416
  });
1324
1417
  // Display rename only — the slug stays put (registry ids `<tenantSlug>/<name>` and
@@ -1355,11 +1448,9 @@ export function createControlPlaneApi(options) {
1355
1448
  const { backup: wantsBackup } = await readJsonBody(c, reapScopeBody);
1356
1449
  const tenant = await c.var.admin.getTenant(actor, tenantId);
1357
1450
  if (!tenant)
1358
- return c.json({ error: `unknown tenant: ${tenantId}` }, 404);
1451
+ return refuse(c, 'not_found', `unknown tenant: ${tenantId}`);
1359
1452
  if (tenant.status !== 'deleting') {
1360
- return c.json({
1361
- error: `tenant ${tenantId} is ${tenant.status}, not deleting — only a deleting tenant may be reaped`,
1362
- }, 409);
1453
+ return refuse(c, 'conflict', `tenant ${tenantId} is ${tenant.status}, not deleting — only a deleting tenant may be reaped`);
1363
1454
  }
1364
1455
  try {
1365
1456
  for (const scope of await c.var.admin.listScopes(actor, { tenantId })) {
@@ -1390,7 +1481,7 @@ export function createControlPlaneApi(options) {
1390
1481
  }
1391
1482
  catch (e) {
1392
1483
  if (e instanceof ControlPlaneError) {
1393
- return c.json({ error: e.message }, failedWith(c, e));
1484
+ return relayRefusal(c, e);
1394
1485
  }
1395
1486
  throw e;
1396
1487
  }
@@ -1412,10 +1503,10 @@ export function createControlPlaneApi(options) {
1412
1503
  // `builder`, a plan-gated feature, a key no vertical asked for. And never the plan
1413
1504
  // half: an expiry, a quota or a plan name is pricing, which is not the tenant's.
1414
1505
  if (plan.expiresAt != null || plan.quota != null || plan.plan != null) {
1415
- return c.json({ error: 'a tenant credential grants entitlements without plan fields' }, 403);
1506
+ return refuse(c, 'forbidden', 'a tenant credential grants entitlements without plan fields');
1416
1507
  }
1417
1508
  if (!(await tenantGrantable(p, key))) {
1418
- return c.json({ error: `entitlement '${key}' is not declared by a vertical this tenant can install` }, 403);
1509
+ return refuse(c, 'forbidden', `entitlement '${key}' is not declared by a vertical this tenant can install`);
1419
1510
  }
1420
1511
  }
1421
1512
  await c.var.admin.grantEntitlement(c.get('actor'), tenantId, key, plan);
@@ -1666,7 +1757,7 @@ export function createControlPlaneApi(options) {
1666
1757
  // worse this week" is the question it exists for.
1667
1758
  app.get('/connections/calls', async (c) => {
1668
1759
  if (!options.observability?.connectorCallsSeries) {
1669
- return c.json({ error: 'connector-call analytics are not configured on this control plane' }, 501);
1760
+ return refuseStatus(c, 501, 'connector-call analytics are not configured on this control plane');
1670
1761
  }
1671
1762
  const input = z
1672
1763
  .object({
@@ -1683,7 +1774,7 @@ export function createControlPlaneApi(options) {
1683
1774
  // credential can reach only this path, never the fleet-wide sibling above.
1684
1775
  app.get('/tenants/:tenantId/connections/calls', async (c) => {
1685
1776
  if (!options.observability?.connectorCallsSeries) {
1686
- return c.json({ error: 'connector-call analytics are not configured on this control plane' }, 501);
1777
+ return refuseStatus(c, 501, 'connector-call analytics are not configured on this control plane');
1687
1778
  }
1688
1779
  const input = z.object({
1689
1780
  tenantId: tenantIdSchema,
@@ -1780,7 +1871,7 @@ export function createControlPlaneApi(options) {
1780
1871
  const raw = await c.req.json();
1781
1872
  const body = typeof raw === 'object' && raw !== null ? raw : {};
1782
1873
  if (body.tenantId !== undefined && body.tenantId !== tenantId) {
1783
- return c.json({ error: 'body tenantId disagrees with the route tenant' }, 400);
1874
+ return refuse(c, 'validation_failed', 'body tenantId disagrees with the route tenant');
1784
1875
  }
1785
1876
  try {
1786
1877
  return c.json(await relayConnectionUpsert(c.var.host, c.get('actor'), { ...body, tenantId }, { probeCandidate, prepareCandidate }));
@@ -1789,7 +1880,7 @@ export function createControlPlaneApi(options) {
1789
1880
  if (err instanceof ConnectionRelayError && err.status < 500) {
1790
1881
  // A refusal carries the provider's own answer, so a console can show WHY rather
1791
1882
  // than "save failed" — the whole point of checking before writing.
1792
- return c.json({ error: err.message, ...(err.probe ? { probe: err.probe } : {}) }, err.status);
1883
+ return refuseStatus(c, err.status, err.message, err.probe ? { probe: err.probe } : undefined);
1793
1884
  }
1794
1885
  // A 5xx relay refusal (#603: this deployment holds no seal key) is the PLATFORM
1795
1886
  // failing, not the caller — rethrown so the shared boundary maps it (still a 503
@@ -1808,7 +1899,7 @@ export function createControlPlaneApi(options) {
1808
1899
  const rows = await c.var.admin.listConnections(c.get('actor'), { tenantId, includeRevoked: true });
1809
1900
  const row = rows.find((r) => r.id === id);
1810
1901
  if (!row)
1811
- return c.json({ error: 'unknown connection' }, 404);
1902
+ return refuse(c, 'not_found', 'unknown connection');
1812
1903
  if (row.status !== 'revoked')
1813
1904
  await c.var.admin.revokeConnection(c.get('actor'), row.id);
1814
1905
  return c.body(null, 204);
@@ -1846,7 +1937,7 @@ export function createControlPlaneApi(options) {
1846
1937
  scopeId,
1847
1938
  id: connectLinkId.parse(c.req.param('linkId')),
1848
1939
  });
1849
- return link ? c.json(link) : c.json({ error: 'unknown connect link' }, 404);
1940
+ return link ? c.json(link) : refuse(c, 'not_found', 'unknown connect link');
1850
1941
  });
1851
1942
  // The callback's spend. A refusal is a 200 carrying the typed reason, not an error status:
1852
1943
  // "already used" is the answer to the question asked, and the callback renders each one.
@@ -1885,7 +1976,7 @@ export function createControlPlaneApi(options) {
1885
1976
  scopeId,
1886
1977
  id: connectLinkId.parse(c.req.param('linkId')),
1887
1978
  });
1888
- return link ? c.body(null, 204) : c.json({ error: 'unknown connect link' }, 404);
1979
+ return link ? c.body(null, 204) : refuse(c, 'not_found', 'unknown connect link');
1889
1980
  });
1890
1981
  /**
1891
1982
  * The tenant-scoped connection lookup the two inspection routes share. Same K-3
@@ -1907,6 +1998,83 @@ export function createControlPlaneApi(options) {
1907
1998
  const rows = await c.var.admin.listConnections(c.get('actor'), { tenantId });
1908
1999
  return rows.find((r) => r.id === id);
1909
2000
  };
2001
+ // Staff/service only: deliberately absent from BUILDER_ROUTES and TENANT_ROUTES.
2002
+ // No generic connector-state write and no grant escalation: the connector verifies the
2003
+ // scope's delivered permission again before writing its own binding record.
2004
+ app.get('/tenants/:tenantId/connections/:id/bindings', async (c) => {
2005
+ const row = await inspectableConnection(c);
2006
+ if (!row)
2007
+ return refuse(c, 'not_found', 'unknown connection');
2008
+ const read = options.connectionInspectors?.[row.provider]?.bindings;
2009
+ if (!read)
2010
+ return refuseStatus(c, 501, `no scope binding registered for provider '${row.provider}'`);
2011
+ const bindings = await read(c.var.host, row);
2012
+ return c.json(await Promise.all(bindings.map(async (binding) => {
2013
+ const scope = scopeIdSchema.parse(binding.scopeId);
2014
+ try {
2015
+ const grants = await c.var.host.connectionGrantsInScope(row.tenantId, scope);
2016
+ return { ...binding, permissionHeld: grants.some((g) => g.connectionId === row.id && g.permission === binding.permission), permissionInspectionError: undefined };
2017
+ }
2018
+ catch (error) {
2019
+ // A stored target remains inspectable when its scope is inactive or its
2020
+ // deployment is unavailable. Unknown authority must not read as a missing grant.
2021
+ return { ...binding, permissionHeld: undefined, permissionInspectionError: error instanceof Error ? error.message : String(error) };
2022
+ }
2023
+ })));
2024
+ });
2025
+ app.put('/tenants/:tenantId/connections/:id/bindings/:scopeId', async (c) => {
2026
+ const row = await inspectableConnection(c);
2027
+ if (!row)
2028
+ return refuse(c, 'not_found', 'unknown connection');
2029
+ const bind = options.connectionInspectors?.[row.provider]?.bindScope;
2030
+ if (!bind)
2031
+ return refuseStatus(c, 501, `no scope binding registered for provider '${row.provider}'`);
2032
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
2033
+ const target = await c.var.admin.getScopeRecord(c.get('actor'), row.tenantId, scopeId);
2034
+ if (!target)
2035
+ return refuse(c, 'not_found', 'unknown scope');
2036
+ if (!isPrimaryScope(target))
2037
+ return refuseStatus(c, 403, PREVIEW_CONNECTIONS_REFUSAL);
2038
+ if (target.status !== 'active' || !target.vertical || target.vertical !== row.vertical) {
2039
+ return refuseStatus(c, 409, 'connection and target must share an active vertical scope');
2040
+ }
2041
+ const fields = {
2042
+ operation: z.string().regex(/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/),
2043
+ permission: permissionKey,
2044
+ };
2045
+ const schema = row.provider === 'planima'
2046
+ ? z.object({ ...fields,
2047
+ organizationId: z.number().int().positive().nullable().optional(),
2048
+ currency: z.string().regex(/^[A-Z]{3}$/).optional(),
2049
+ window: z.object({ fromYear: z.number().int(), toYear: z.number().int() })
2050
+ .refine((w) => w.toYear >= w.fromYear, 'window ends before it starts').nullable().optional(),
2051
+ horizonYears: z.number().int().min(0).optional(),
2052
+ }).strict()
2053
+ : row.provider === 'microsoft365'
2054
+ // #2101: the channel folder, by its drive path. The connector resolves it and holds the
2055
+ // fence to it; this only bounds the input.
2056
+ ? z.object({ ...fields, folder: z.string().trim().min(1).max(400) }).strict()
2057
+ : z.object(fields).strict();
2058
+ const input = schema.parse(await c.req.json());
2059
+ const grants = await c.var.host.connectionGrantsInScope(row.tenantId, scopeId);
2060
+ if (!grants.some((g) => g.connectionId === row.id && g.permission === input.permission)) {
2061
+ return refuseStatus(c, 409, `connection does not hold '${input.permission}' on scope ${scopeId}; grant it through the tenant's authorized connection flow, then bind`);
2062
+ }
2063
+ const operationId = ulid();
2064
+ const done = await auditedChange({
2065
+ flow: 'connection-binding', operationId,
2066
+ run: () => bind(c.var.host, row, { ...input, tenantId: row.tenantId, scopeId, vertical: target.vertical }),
2067
+ refused: () => false,
2068
+ record: (phase) => c.var.admin.recordConnectionBindingChange(c.get('actor'), {
2069
+ tenantId: row.tenantId, scopeId, connectionId: row.id, provider: row.provider,
2070
+ ...input,
2071
+ operationId, ...(phase.phase === 'applied' ? { phase: 'applied' } : phase),
2072
+ }),
2073
+ });
2074
+ if ('error' in done)
2075
+ throw done.error;
2076
+ return c.json({ ...done.result, permissionHeld: true, ...('unrecorded' in done ? { auditWarning: done.unrecorded } : {}) });
2077
+ });
1910
2078
  /**
1911
2079
  * **Verify a credential against the provider** (#605) — the read that turns "stored"
1912
2080
  * into "works, and for this account".
@@ -1924,10 +2092,10 @@ export function createControlPlaneApi(options) {
1924
2092
  app.post('/tenants/:tenantId/connections/:id/verify', async (c) => {
1925
2093
  const row = await inspectableConnection(c);
1926
2094
  if (!row)
1927
- return c.json({ error: 'unknown connection' }, 404);
2095
+ return refuse(c, 'not_found', 'unknown connection');
1928
2096
  const probe = options.connectionInspectors?.[row.provider]?.probe;
1929
2097
  if (!probe) {
1930
- return c.json({ error: `no probe registered for provider '${row.provider}'` }, 501);
2098
+ return refuseStatus(c, 501, `no probe registered for provider '${row.provider}'`);
1931
2099
  }
1932
2100
  return c.json(connectionProbe.parse(await probe(c.var.host, row)));
1933
2101
  });
@@ -1946,10 +2114,10 @@ export function createControlPlaneApi(options) {
1946
2114
  app.get('/tenants/:tenantId/connections/:id/activity', async (c) => {
1947
2115
  const row = await inspectableConnection(c);
1948
2116
  if (!row)
1949
- return c.json({ error: 'unknown connection' }, 404);
2117
+ return refuse(c, 'not_found', 'unknown connection');
1950
2118
  const activity = options.connectionInspectors?.[row.provider]?.activity;
1951
2119
  if (!activity) {
1952
- return c.json({ error: `no activity view registered for provider '${row.provider}'` }, 501);
2120
+ return refuseStatus(c, 501, `no activity view registered for provider '${row.provider}'`);
1953
2121
  }
1954
2122
  const source = connectionActivitySource.catch('ledger').parse(c.req.query('source'));
1955
2123
  const live = c.req.query('live') === '1';
@@ -1967,10 +2135,10 @@ export function createControlPlaneApi(options) {
1967
2135
  app.get('/tenants/:tenantId/connections/:id/credential', async (c) => {
1968
2136
  const row = await inspectableConnection(c);
1969
2137
  if (!row)
1970
- return c.json({ error: 'unknown connection' }, 404);
2138
+ return refuse(c, 'not_found', 'unknown connection');
1971
2139
  const credential = options.connectionInspectors?.[row.provider]?.credential;
1972
2140
  if (!credential) {
1973
- return c.json({ error: `no credential view registered for provider '${row.provider}'` }, 501);
2141
+ return refuseStatus(c, 501, `no credential view registered for provider '${row.provider}'`);
1974
2142
  }
1975
2143
  return c.json(connectionCredential.parse(await credential(c.var.host, row)));
1976
2144
  });
@@ -1982,10 +2150,10 @@ export function createControlPlaneApi(options) {
1982
2150
  app.get('/tenants/:tenantId/connections/:id/certificate', async (c) => {
1983
2151
  const row = await inspectableConnection(c);
1984
2152
  if (!row)
1985
- return c.json({ error: 'unknown connection' }, 404);
2153
+ return refuse(c, 'not_found', 'unknown connection');
1986
2154
  const certificate = await options.connectionInspectors?.[row.provider]?.certificate?.(c.var.host, row);
1987
2155
  if (!certificate)
1988
- return c.json({ error: 'this connection signs in with no certificate' }, 404);
2156
+ return refuse(c, 'not_found', 'this connection signs in with no certificate');
1989
2157
  return c.json(connectionCertificate.parse(certificate));
1990
2158
  });
1991
2159
  // -- the scope directory (§3.2/§4.2) ---------------------------------------
@@ -2043,7 +2211,7 @@ export function createControlPlaneApi(options) {
2043
2211
  // A builder acts only within its own tenant; a foreign tenant is hidden as 404 (K-3),
2044
2212
  // never distinguished from "no such scope".
2045
2213
  if (outsideTenant(p, tenantId)) {
2046
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${input.parentScopeId})` }, 404);
2214
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${input.parentScopeId})`);
2047
2215
  }
2048
2216
  // The provisioning sequence lives in one place — `provisionSiblingScope` — shared with the
2049
2217
  // platform-intent drain's `provision-sibling` handler (platform-intents.md B2).
@@ -2057,12 +2225,12 @@ export function createControlPlaneApi(options) {
2057
2225
  owner: input.owner,
2058
2226
  });
2059
2227
  if (!result.ok)
2060
- return c.json({ error: result.error }, result.status);
2228
+ return refuseStatus(c, result.status, result.error);
2061
2229
  return c.json(await c.var.admin.getScopeRecord(actor, tenantId, input.scopeId), 201);
2062
2230
  }
2063
2231
  catch (e) {
2064
2232
  if (e instanceof ControlPlaneError)
2065
- return c.json({ error: e.message }, failedWith(c, e));
2233
+ return relayRefusal(c, e);
2066
2234
  throw e;
2067
2235
  }
2068
2236
  });
@@ -2072,12 +2240,12 @@ export function createControlPlaneApi(options) {
2072
2240
  // A builder reads only its own tenant; a foreign tenant is hidden as 404 (K-3).
2073
2241
  const p = c.get('principal');
2074
2242
  if (outsideTenant(p, tenantId)) {
2075
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
2243
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
2076
2244
  }
2077
2245
  const record = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
2078
2246
  // Absent, or present under another tenant — indistinguishable on purpose (K-3).
2079
2247
  if (!record)
2080
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
2248
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
2081
2249
  return c.json(record);
2082
2250
  });
2083
2251
  // Read-only introspection of a scope's own database (kernel-design §5.4 admin-query
@@ -2330,6 +2498,27 @@ export function createControlPlaneApi(options) {
2330
2498
  }
2331
2499
  return { refused, ...(refusal ? { refusal } : {}) };
2332
2500
  };
2501
+ /**
2502
+ * The two sides of a rebind review (#2347), as the host stores them: the version the scope is
2503
+ * bound to (null: none, or one its lineage no longer holds) and the target lineage's serving
2504
+ * version, each with its stored migrations. The read route and the rebind gate both read
2505
+ * through here, so the digest a review hands out and the one the rebind recomputes are taken
2506
+ * from the same rows the same way.
2507
+ */
2508
+ const rebindSidesOf = async (c, scope, target, servingVersionId) => {
2509
+ const actor = c.get('actor');
2510
+ const side = async (slug, id) => {
2511
+ const v = await c.var.admin.getVersion(actor, id, slug);
2512
+ return v ? { ...v, migrations: await c.var.admin.versionMigrations(actor, slug, id) } : null;
2513
+ };
2514
+ const [base, incoming] = await Promise.all([
2515
+ scope.vertical && scope.verticalVersionId ? side(scope.vertical, scope.verticalVersionId) : null,
2516
+ side(target, servingVersionId),
2517
+ ]);
2518
+ if (!incoming)
2519
+ throw substratError('not_found', `unknown serving version for vertical '${target}'`);
2520
+ return { base, target: incoming };
2521
+ };
2333
2522
  /**
2334
2523
  * Move ONE scope onto a DIFFERENT vertical lineage's serving script (#389) — the
2335
2524
  * update-rebind behind retiring a platform-owned lineage in favour of a tenant-owned
@@ -2367,13 +2556,15 @@ export function createControlPlaneApi(options) {
2367
2556
  await c.var.admin.reassertSystemSwitches(actor, { tenantId, scopeId });
2368
2557
  return { servingRef: serving.ref, versionId: serving.versionId, alreadyBound: true };
2369
2558
  }
2559
+ // #2347: the review binding is the rebind's own; what the host's bind takes is the rest.
2560
+ const { review, ...bindAck } = opts.acknowledge ?? {};
2370
2561
  // #1756: within one lineage this is an adopt, and judged as one before anything moves. A
2371
2562
  // crossing is not judged (`bindExportBreaksOf`): the producer's slug itself changes.
2372
2563
  await refuseOnBreaks(actor, tenantId, scopeId, serving.versionId, {
2373
2564
  servingRef: serving.ref,
2374
- acknowledge: opts.acknowledge,
2565
+ acknowledge: bindAck,
2375
2566
  });
2376
- const move = { acknowledge: opts.acknowledge };
2567
+ const move = { acknowledge: bindAck };
2377
2568
  if (opts.abandonData) {
2378
2569
  // Directory-only crossing: no bytes move, so the frontier gate has nothing to
2379
2570
  // protect — the target provisions its own schema from scratch. The source script's
@@ -2402,15 +2593,22 @@ export function createControlPlaneApi(options) {
2402
2593
  // what this DO has already applied plus whatever a same-lineage update would have
2403
2594
  // brought — the crossing is then no riskier than an ordinary version bind. Anything
2404
2595
  // else (differing digests, or a scope with no bound version to compare) needs the
2405
- // operator's explicit acknowledgement.
2406
- const targetV = await c.var.admin.getVersion(actor, serving.versionId, target);
2407
- const currentV = scope.verticalVersionId
2408
- ? await c.var.admin.getVersion(actor, scope.verticalVersionId, scope.vertical)
2409
- : undefined;
2410
- const digestsMatch = Boolean(currentV && targetV && currentV.migrationDigest === targetV.migrationDigest);
2596
+ // operator's explicit acknowledgement, bound to the review it answers (#2347).
2597
+ const sides = await rebindSidesOf(c, scope, target, serving.versionId);
2598
+ const digestsMatch = Boolean(sides.base && sides.base.migrationDigest === sides.target.migrationDigest);
2599
+ await assertRebindReviewCurrent({
2600
+ ackMigrations: opts.ackMigrations === true,
2601
+ review,
2602
+ target,
2603
+ baseId: scope.verticalVersionId ?? null,
2604
+ migrationsChange: !digestsMatch,
2605
+ read: async () => ({ base: sides.base, baseVertical: scope.vertical, target: sides.target }),
2606
+ });
2411
2607
  if (!digestsMatch && !opts.ackMigrations) {
2412
- throw new ControlPlaneError(409, `migration surfaces differ across lineages ('${scope.vertical}' ${currentV?.migrationDigest ?? '(unbound)'} → ` +
2413
- `'${target}' ${targetV?.migrationDigest ?? '(unknown)'}) — read both migration diffs, then re-run with ackMigrations`);
2608
+ throw new ControlPlaneError(409, `migration surfaces differ across lineages ('${scope.vertical}' ${sides.base?.migrationDigest ?? '(unbound)'} → ` +
2609
+ `'${target}' ${sides.target.migrationDigest}) — read the rebind review (GET ` +
2610
+ `/tenants/:tenantId/scopes/:scopeId/rebind-review?vertical=<target>), then re-run with ackMigrations and the ` +
2611
+ 'acknowledge.review it returned');
2414
2612
  }
2415
2613
  const source = await verticalForScope(c, scope);
2416
2614
  const dest = await options.resolveVerticalRef?.(serving.ref);
@@ -2421,8 +2619,17 @@ export function createControlPlaneApi(options) {
2421
2619
  // pointer cross. `bindScopeVersion` rewrites `scopes.vertical` from the version row, audited.
2422
2620
  // No snapshot is taken here: the old copy is swept once the move is confirmed, so an
2423
2621
  // operator who wants a way back takes a snapshot or backup first.
2424
- const restored = await moveOntoServing(c, scope, source, dest, serving.ref, move);
2425
- await c.var.admin.bindScopeVersion(actor, tenantId, scopeId, serving.versionId, move).catch(relayHostRefusal);
2622
+ // #2347: the route flips and the pointer crosses in ONE write, and only if the scope is still
2623
+ // bound to the version the gate judged and the target still serves the version it judged: a
2624
+ // promote on either lineage since then is refused as stale, and the move settles `retained`.
2625
+ const restored = await moveOntoServing(c, scope, source, dest, serving.ref, {
2626
+ ...move,
2627
+ bind: {
2628
+ versionId: serving.versionId,
2629
+ expectedVersionId: scope.verticalVersionId ?? null,
2630
+ expectedServing: { vertical: target, ref: serving.ref, versionId: serving.versionId },
2631
+ },
2632
+ });
2426
2633
  // #1674: re-assert the recorded OFF positions in the store the scope now routes to.
2427
2634
  await c.var.admin.reassertSystemSwitches(actor, { tenantId, scopeId }, { appliedInUnit: restored.switchedOff });
2428
2635
  return { servingRef: serving.ref, versionId: serving.versionId, tables: restored.tables };
@@ -2462,12 +2669,18 @@ export function createControlPlaneApi(options) {
2462
2669
  scopeId, exact: true, loadStamp: restoredStamp,
2463
2670
  ...(moveId ? { fence: copyRestoreFence(moveId, recordedAt, copyLeaseMs, erasureEpoch) } : {}),
2464
2671
  });
2465
- await c.var.admin.setScopeServingRef(actor, tenantId, scopeId, servingRef, {
2466
- ...routeOpts, expectedErasureEpoch: erasureEpoch,
2672
+ const flip = {
2673
+ acknowledge: routeOpts.acknowledge, expectedErasureEpoch: erasureEpoch,
2467
2674
  ...(moveId ? { confirmMove: sourceStamp
2468
2675
  ? { moveId, source: 'eligible', sourceMarker: { loadStamp: sourceStamp, revision: sourceRevision } }
2469
2676
  : { moveId, source: 'retained' } } : {}),
2470
- }).catch(relayHostRefusal);
2677
+ };
2678
+ const { bind } = routeOpts;
2679
+ await (bind
2680
+ ? c.var.admin.bindScopeVersion(actor, tenantId, scopeId, bind.versionId, {
2681
+ ...flip, servingRef, expectedVersionId: bind.expectedVersionId, expectedServing: bind.expectedServing,
2682
+ })
2683
+ : c.var.admin.setScopeServingRef(actor, tenantId, scopeId, servingRef, flip)).catch(relayHostRefusal);
2471
2684
  return restored;
2472
2685
  }
2473
2686
  catch (error) {
@@ -3023,7 +3236,7 @@ export function createControlPlaneApi(options) {
3023
3236
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
3024
3237
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3025
3238
  if (!scope)
3026
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3239
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3027
3240
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'listScopeTables', null, {
3028
3241
  viaVertical: (v) => v.listScopeTables(scopeId),
3029
3242
  colocated: () => c.var.admin.listScopeTables(c.get('actor'), tenantId, scopeId),
@@ -3053,11 +3266,11 @@ export function createControlPlaneApi(options) {
3053
3266
  // A builder reads only its own tenant; a foreign tenant is hidden as 404 (K-3).
3054
3267
  const principal = c.get('principal');
3055
3268
  if (outsideTenant(principal, tenantId)) {
3056
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3269
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3057
3270
  }
3058
3271
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3059
3272
  if (!scope)
3060
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3273
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3061
3274
  // Health reads the scope the same way the Data view does, so it leaves the same
3062
3275
  // row: an auditor should not be able to tell which route reached the tables.
3063
3276
  const tables = await delegatedRead(c, tenantId, scopeId, scope, 'listScopeTables', null, {
@@ -3105,7 +3318,7 @@ export function createControlPlaneApi(options) {
3105
3318
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
3106
3319
  const principal = c.get('principal');
3107
3320
  if (outsideTenant(principal, tenantId)) {
3108
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3321
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3109
3322
  }
3110
3323
  const filter = platformRequestFilter.parse({
3111
3324
  kind: c.req.query('kind'),
@@ -3114,7 +3327,7 @@ export function createControlPlaneApi(options) {
3114
3327
  });
3115
3328
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3116
3329
  if (!scope)
3117
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3330
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3118
3331
  // Same delegation ladder as the table reads: the intents live in the DO of the scope's BOUND
3119
3332
  // version, so a hosted scope is asked through its vertical and a co-located one locally.
3120
3333
  const vertical = await verticalForScope(c, scope);
@@ -3135,17 +3348,17 @@ export function createControlPlaneApi(options) {
3135
3348
  const actor = c.get('actor');
3136
3349
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
3137
3350
  if (!scope)
3138
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3351
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3139
3352
  const p = c.get('principal');
3140
3353
  const scopePin = confinedTenant(p);
3141
3354
  if (scopePin) {
3142
3355
  const v = scope.vertical ? await verticalOf(actor, scope.vertical) : undefined;
3143
3356
  if (!v || v.ownerTenant !== scopePin)
3144
- return c.json({ error: 'forbidden' }, 403);
3357
+ return refuse(c, 'forbidden', 'forbidden');
3145
3358
  }
3146
3359
  const reached = await deploymentForScope(c, scope);
3147
3360
  if (!reached)
3148
- return c.json({ error: await diagnoseUnboundScope(c.get('actor'), scope) }, 501);
3361
+ return refuseStatus(c, 501, await diagnoseUnboundScope(c.get('actor'), scope));
3149
3362
  const vertical = reached.client;
3150
3363
  const entitlements = await c.var.admin.listEntitlements(actor, tenantId);
3151
3364
  // #406: re-gathered and re-delivered like entitlements, so a reconcile also repairs a
@@ -3299,7 +3512,7 @@ export function createControlPlaneApi(options) {
3299
3512
  // unbound store is exactly what makes a vertical refuse its own reconcile), so
3300
3513
  // it must not be the half that gets dropped.
3301
3514
  const detail = storeErrors.length ? `${e.message} (stores: ${storeErrors.join('; ')})` : e.message;
3302
- return c.json({ error: detail }, failedWith(c, e));
3515
+ return refuseStatus(c, failedWith(c, e), detail);
3303
3516
  }
3304
3517
  throw e;
3305
3518
  }
@@ -3321,7 +3534,7 @@ export function createControlPlaneApi(options) {
3321
3534
  });
3322
3535
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3323
3536
  if (!scope)
3324
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3537
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3325
3538
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'facetEvents', input, {
3326
3539
  // #1762: only the vertical's answer needs the guard. The co-located read runs
3327
3540
  // this platform's own kernel, which withholds by construction.
@@ -3390,7 +3603,7 @@ export function createControlPlaneApi(options) {
3390
3603
  });
3391
3604
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3392
3605
  if (!scope)
3393
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3606
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3394
3607
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'eventCause', input, {
3395
3608
  viaVertical: (v) => v.eventCause(scopeId, input),
3396
3609
  colocated: () => c.var.admin.eventCause(c.get('actor'), tenantId, scopeId, input),
@@ -3406,7 +3619,7 @@ export function createControlPlaneApi(options) {
3406
3619
  });
3407
3620
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3408
3621
  if (!scope)
3409
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3622
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3410
3623
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'eventEffects', input, {
3411
3624
  viaVertical: (v) => v.eventEffects(scopeId, input),
3412
3625
  colocated: () => c.var.admin.eventEffects(c.get('actor'), tenantId, scopeId, input),
@@ -3423,7 +3636,7 @@ export function createControlPlaneApi(options) {
3423
3636
  });
3424
3637
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3425
3638
  if (!scope)
3426
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3639
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3427
3640
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'invocationEvents', input, {
3428
3641
  viaVertical: (v) => v.invocationEvents(scopeId, input),
3429
3642
  colocated: () => c.var.admin.invocationEvents(c.get('actor'), tenantId, scopeId, input),
@@ -3440,7 +3653,7 @@ export function createControlPlaneApi(options) {
3440
3653
  });
3441
3654
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3442
3655
  if (!scope)
3443
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3656
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3444
3657
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'deadLetters', input, {
3445
3658
  viaVertical: (v) => v.deadLetters(scopeId, input),
3446
3659
  colocated: () => c.var.admin.deadLetters(c.get('actor'), tenantId, scopeId, input),
@@ -3455,7 +3668,7 @@ export function createControlPlaneApi(options) {
3455
3668
  const input = lifecycleFlowInput.parse(await c.req.json());
3456
3669
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3457
3670
  if (!scope)
3458
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3671
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3459
3672
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'lifecycleFlow', input, {
3460
3673
  viaVertical: (v) => v.lifecycleFlow(scopeId, input),
3461
3674
  colocated: () => c.var.admin.lifecycleFlow(c.get('actor'), tenantId, scopeId, input),
@@ -3470,7 +3683,7 @@ export function createControlPlaneApi(options) {
3470
3683
  const input = operationSeriesInput.parse(await c.req.json());
3471
3684
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3472
3685
  if (!scope)
3473
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3686
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3474
3687
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'operationSeries', input, {
3475
3688
  viaVertical: (v) => v.operationSeries(scopeId, input),
3476
3689
  colocated: () => c.var.admin.operationSeries(c.get('actor'), tenantId, scopeId, input),
@@ -3490,7 +3703,7 @@ export function createControlPlaneApi(options) {
3490
3703
  });
3491
3704
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3492
3705
  if (!scope)
3493
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3706
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3494
3707
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'entityHistory', input, {
3495
3708
  viaVertical: (v) => v.entityHistory(scopeId, input),
3496
3709
  colocated: () => c.var.admin.entityHistory(c.get('actor'), tenantId, scopeId, input),
@@ -3506,7 +3719,7 @@ export function createControlPlaneApi(options) {
3506
3719
  });
3507
3720
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3508
3721
  if (!scope)
3509
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3722
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3510
3723
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'readScopeTable', input, {
3511
3724
  viaVertical: (v) => v.readScopeTable(scopeId, input),
3512
3725
  colocated: () => c.var.admin.readScopeTable(c.get('actor'), tenantId, scopeId, input),
@@ -3523,7 +3736,7 @@ export function createControlPlaneApi(options) {
3523
3736
  const input = queryScopeInput.parse(await c.req.json());
3524
3737
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3525
3738
  if (!scope)
3526
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3739
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3527
3740
  return c.json(await delegatedRead(
3528
3741
  // The SQL is the argument the co-located branch logs, so it is the argument
3529
3742
  // here — an audit row for the read-only console that did not carry the
@@ -3549,12 +3762,12 @@ export function createControlPlaneApi(options) {
3549
3762
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
3550
3763
  const principal = c.get('principal');
3551
3764
  if (outsideTenant(principal, tenantId)) {
3552
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3765
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3553
3766
  }
3554
3767
  const filter = denialLogQuery.parse(c.req.query());
3555
3768
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3556
3769
  if (!scope)
3557
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3770
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3558
3771
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'listDenials', filter, {
3559
3772
  viaVertical: (v) => v.listDenials(scopeId, filter),
3560
3773
  colocated: () => c.var.admin.listDenials(c.get('actor'), tenantId, scopeId, filter),
@@ -3565,12 +3778,12 @@ export function createControlPlaneApi(options) {
3565
3778
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
3566
3779
  const principal = c.get('principal');
3567
3780
  if (outsideTenant(principal, tenantId)) {
3568
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3781
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3569
3782
  }
3570
3783
  const filter = denialLogQuery.parse(c.req.query());
3571
3784
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3572
3785
  if (!scope)
3573
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3786
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3574
3787
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'summarizeDenials', filter, {
3575
3788
  viaVertical: (v) => v.summarizeDenials(scopeId, filter),
3576
3789
  colocated: () => c.var.admin.summarizeDenials(c.get('actor'), tenantId, scopeId, filter),
@@ -3583,14 +3796,14 @@ export function createControlPlaneApi(options) {
3583
3796
  // does not rest on which list the route is absent from. Delegated like the denial log.
3584
3797
  app.get('/tenants/:tenantId/scopes/:scopeId/capabilities', async (c) => {
3585
3798
  if (confinedTenant(c.get('principal')) !== null) {
3586
- return c.json({ error: 'forbidden: the capability directory is staff-only' }, 403);
3799
+ return refuse(c, 'forbidden', 'forbidden: the capability directory is staff-only');
3587
3800
  }
3588
3801
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
3589
3802
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
3590
3803
  const filter = capabilityFilterQuery.parse(c.req.query());
3591
3804
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3592
3805
  if (!scope)
3593
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3806
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3594
3807
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'listCapabilities', filter, {
3595
3808
  viaVertical: (v) => v.listCapabilities(scopeId, filter),
3596
3809
  colocated: () => c.var.admin.listCapabilities(c.get('actor'), tenantId, scopeId, filter),
@@ -3604,7 +3817,7 @@ export function createControlPlaneApi(options) {
3604
3817
  // the scope does not hold is 404; a deployment built before the far end answers 501 "redeploy".
3605
3818
  app.post('/tenants/:tenantId/scopes/:scopeId/capabilities/:capabilityId/revoke', async (c) => {
3606
3819
  if (confinedTenant(c.get('principal')) !== null) {
3607
- return c.json({ error: 'forbidden: revoking a capability is staff-only' }, 403);
3820
+ return refuse(c, 'forbidden', 'forbidden: revoking a capability is staff-only');
3608
3821
  }
3609
3822
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
3610
3823
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
@@ -3625,20 +3838,20 @@ export function createControlPlaneApi(options) {
3625
3838
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
3626
3839
  const principal = c.get('principal');
3627
3840
  if (outsideTenant(principal, tenantId)) {
3628
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3841
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3629
3842
  }
3630
3843
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3631
3844
  if (!scope)
3632
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3845
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3633
3846
  const vertical = await verticalForScope(c, scope);
3634
3847
  if (!vertical)
3635
- return c.json({ error: await diagnoseUnboundScope(c.get('actor'), scope) }, 501);
3848
+ return refuseStatus(c, 501, await diagnoseUnboundScope(c.get('actor'), scope));
3636
3849
  try {
3637
3850
  return c.json(await vertical.ownerSeat(tenantId, scopeId));
3638
3851
  }
3639
3852
  catch (e) {
3640
3853
  if (e instanceof ControlPlaneError)
3641
- return c.json({ error: e.message }, failedWith(c, e));
3854
+ return relayRefusal(c, e);
3642
3855
  throw e;
3643
3856
  }
3644
3857
  });
@@ -3660,25 +3873,25 @@ export function createControlPlaneApi(options) {
3660
3873
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
3661
3874
  const principal = c.get('principal');
3662
3875
  if (outsideTenant(principal, tenantId)) {
3663
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3876
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3664
3877
  }
3665
3878
  const actor = c.get('actor');
3666
3879
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
3667
3880
  if (!scope)
3668
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3881
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3669
3882
  const vertical = await verticalForScope(c, scope);
3670
3883
  if (!vertical)
3671
- return c.json({ error: await diagnoseUnboundScope(actor, scope) }, 501);
3884
+ return refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope));
3672
3885
  const origin = await instanceOrigin(c.var.admin, actor, scopeId);
3673
3886
  if (!origin) {
3674
- return c.json({ error: `scope ${scopeId} has no hostname bound — a claim link needs an address to open on` }, 409);
3887
+ return refuse(c, 'conflict', `scope ${scopeId} has no hostname bound — a claim link needs an address to open on`);
3675
3888
  }
3676
3889
  try {
3677
3890
  return c.json(await vertical.mintOwnerClaim({ tenantId, scopeId, origin, actor }), 201);
3678
3891
  }
3679
3892
  catch (e) {
3680
3893
  if (e instanceof ControlPlaneError)
3681
- return c.json({ error: e.message }, failedWith(c, e));
3894
+ return relayRefusal(c, e);
3682
3895
  throw e;
3683
3896
  }
3684
3897
  });
@@ -3706,10 +3919,10 @@ export function createControlPlaneApi(options) {
3706
3919
  // K-3: another tenant's pair, or no such scope, reads the same — absent.
3707
3920
  const scope = outsideTenant(principal, tenantId) ? null : await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
3708
3921
  if (!scope)
3709
- return { refusal: c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404) };
3922
+ return { refusal: refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`) };
3710
3923
  const vertical = await verticalForScope(c, scope);
3711
3924
  if (!vertical)
3712
- return { refusal: c.json({ error: await diagnoseUnboundScope(actor, scope) }, 501) };
3925
+ return { refusal: refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope)) };
3713
3926
  return { tenantId, scopeId, actor, vertical, person: memberPerson(principal) };
3714
3927
  };
3715
3928
  /**
@@ -3724,7 +3937,7 @@ export function createControlPlaneApi(options) {
3724
3937
  const { operationId } = done;
3725
3938
  if ('error' in done) {
3726
3939
  if (done.error instanceof ControlPlaneError) {
3727
- return c.json({ error: done.error.message, operationId }, failedWith(c, done.error));
3940
+ return relayRefusal(c, done.error, { operationId });
3728
3941
  }
3729
3942
  throw done.error;
3730
3943
  }
@@ -3763,7 +3976,7 @@ export function createControlPlaneApi(options) {
3763
3976
  }
3764
3977
  catch (e) {
3765
3978
  if (e instanceof ControlPlaneError)
3766
- return c.json({ error: e.message }, failedWith(c, e));
3979
+ return relayRefusal(c, e);
3767
3980
  throw e;
3768
3981
  }
3769
3982
  });
@@ -3772,11 +3985,11 @@ export function createControlPlaneApi(options) {
3772
3985
  if ('refusal' in target)
3773
3986
  return target.refusal;
3774
3987
  if (!target.person)
3775
- return c.json({ error: NO_PERSON }, 403);
3988
+ return refuse(c, 'forbidden', NO_PERSON);
3776
3989
  const body = memberInviteInput.parse(await c.req.json());
3777
3990
  const origin = await instanceOrigin(c.var.admin, target.actor, target.scopeId);
3778
3991
  if (!origin) {
3779
- return c.json({ error: `scope ${target.scopeId} has no hostname bound — an invite link needs an address to open on` }, 409);
3992
+ return refuse(c, 'conflict', `scope ${target.scopeId} has no hostname bound — an invite link needs an address to open on`);
3780
3993
  }
3781
3994
  const { tenantId, scopeId, person } = target;
3782
3995
  const done = await auditedMemberChange(c, { tenantId, scopeId, change: 'invite', caller: person, roleKey: body.roleKey }, () => target.vertical.inviteMember({ tenantId, scopeId, caller: person, origin, roleKey: body.roleKey, email: body.email ?? null }), (link) => ({ principal: link.principal }));
@@ -3787,7 +4000,7 @@ export function createControlPlaneApi(options) {
3787
4000
  if ('refusal' in target)
3788
4001
  return target.refusal;
3789
4002
  if (!target.person)
3790
- return c.json({ error: NO_PERSON }, 403);
4003
+ return refuse(c, 'forbidden', NO_PERSON);
3791
4004
  const member = principalIdSchema.parse(c.req.param('principal'));
3792
4005
  const body = memberRoleInput.parse(await c.req.json());
3793
4006
  const { tenantId, scopeId, person } = target;
@@ -3799,7 +4012,7 @@ export function createControlPlaneApi(options) {
3799
4012
  if ('refusal' in target)
3800
4013
  return target.refusal;
3801
4014
  if (!target.person)
3802
- return c.json({ error: NO_PERSON }, 403);
4015
+ return refuse(c, 'forbidden', NO_PERSON);
3803
4016
  const member = principalIdSchema.parse(c.req.param('principal'));
3804
4017
  const { tenantId, scopeId, person } = target;
3805
4018
  const done = await auditedMemberChange(c, { tenantId, scopeId, change: 'remove', caller: person, principal: member }, () => target.vertical.removeMember({ tenantId, scopeId, caller: person, principal: member }), (removal) => ({ revoked: removal.revoked }));
@@ -3817,7 +4030,7 @@ export function createControlPlaneApi(options) {
3817
4030
  // and a team credential cannot show it is that person asking.
3818
4031
  app.post('/tenants/:tenantId/scopes/:scopeId/owner-transfer', async (c) => {
3819
4032
  if (confinedTenant(c.get('principal')) !== null) {
3820
- return c.json({ error: 'forbidden: an owner hand-over is staff-only' }, 403);
4033
+ return refuse(c, 'forbidden', 'forbidden: an owner hand-over is staff-only');
3821
4034
  }
3822
4035
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
3823
4036
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
@@ -3826,10 +4039,10 @@ export function createControlPlaneApi(options) {
3826
4039
  // K-3 first, so a scope of another tenant reads as absent before anything is reached.
3827
4040
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
3828
4041
  if (!scope)
3829
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
4042
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3830
4043
  const vertical = await verticalForScope(c, scope);
3831
4044
  if (!vertical)
3832
- return c.json({ error: await diagnoseUnboundScope(actor, scope) }, 501);
4045
+ return refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope));
3833
4046
  const operationId = ulid();
3834
4047
  // An abandon (`abandon: true`) is audited the same way, every row marked, so the log tells
3835
4048
  // a hand-over closed without finishing from one that finished.
@@ -3862,17 +4075,17 @@ export function createControlPlaneApi(options) {
3862
4075
  const input = configureInstanceBody.parse(await c.req.json());
3863
4076
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3864
4077
  if (!scope)
3865
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
4078
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3866
4079
  const vertical = await verticalForScope(c, scope);
3867
4080
  if (!vertical) {
3868
- return c.json({ error: await diagnoseUnboundScope(c.get('actor'), scope) }, 501);
4081
+ return refuseStatus(c, 501, await diagnoseUnboundScope(c.get('actor'), scope));
3869
4082
  }
3870
4083
  try {
3871
4084
  await vertical.configureInstance({ tenantId, scopeId, entries: input.entries });
3872
4085
  }
3873
4086
  catch (e) {
3874
4087
  if (e instanceof ControlPlaneError) {
3875
- return c.json({ error: e.message }, failedWith(c, e));
4088
+ return relayRefusal(c, e);
3876
4089
  }
3877
4090
  throw e;
3878
4091
  }
@@ -3929,7 +4142,7 @@ export function createControlPlaneApi(options) {
3929
4142
  message: error,
3930
4143
  }, failure.cause);
3931
4144
  c.set('failureRecorded', true);
3932
- return c.json({ error }, 503);
4145
+ return refuse(c, 'unavailable', error);
3933
4146
  }
3934
4147
  }
3935
4148
  }
@@ -4065,7 +4278,7 @@ export function createControlPlaneApi(options) {
4065
4278
  const body = snapshotScopeBody.parse(await c.req.json().catch(() => ({})));
4066
4279
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
4067
4280
  if (!scope)
4068
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
4281
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4069
4282
  try {
4070
4283
  const { snapId, replay } = await orchestratedSnapshot(c, tenantId, scope, body);
4071
4284
  const record = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, snapId);
@@ -4074,7 +4287,7 @@ export function createControlPlaneApi(options) {
4074
4287
  }
4075
4288
  catch (e) {
4076
4289
  if (e instanceof ControlPlaneError) {
4077
- return c.json({ error: e.message }, failedWith(c, e));
4290
+ return relayRefusal(c, e);
4078
4291
  }
4079
4292
  throw e;
4080
4293
  }
@@ -4120,9 +4333,20 @@ export function createControlPlaneApi(options) {
4120
4333
  */
4121
4334
  const reapScopeStorage = async (c, scope) => {
4122
4335
  const actor = c.get('actor');
4336
+ const dispatch = options.resolveVerticalRef;
4337
+ // #2285: every refusal that can be decided up front comes before anything is destroyed, the
4338
+ // unroute included: a refused reap leaves the scope routed and whole. Then unroute, wipe, delete.
4339
+ if (dispatch)
4340
+ await claimScopeScriptReap({ admin: c.var.admin, actor }, scope.tenantId, scope.id);
4341
+ else
4342
+ await assertNoUnreachableScopeCopies(c.var.admin, actor, scope.tenantId, scope.id);
4343
+ const wipeStores = await platformStoresWipe(c, scope);
4344
+ // Stop routing before the wipe: a call that lands between the wipe and the delete would
4345
+ // recreate the store object, and nothing left could reach it.
4346
+ await unrouteScope(c.var.admin, actor, scope);
4123
4347
  // #2207: the stores first, on every path below: a routed scope's copies are reaped through
4124
4348
  // their scripts, and a store is per scope, not per script.
4125
- await wipePlatformStores(c, scope);
4349
+ await wipeStores();
4126
4350
  // The deployment the scope's vertical resolves to, deleted through; a 501 there is recorded
4127
4351
  // as stranded storage, as `reapScopeScriptCopies` records its own.
4128
4352
  const viaDeployment = async () => {
@@ -4132,13 +4356,11 @@ export function createControlPlaneApi(options) {
4132
4356
  }
4133
4357
  return stranded;
4134
4358
  };
4135
- if (!options.resolveVerticalRef) {
4136
- await assertNoUnreachableScopeCopies(c.var.admin, actor, scope.tenantId, scope.id);
4359
+ if (!dispatch)
4137
4360
  return viaDeployment();
4138
- }
4139
- const unrouted = (await routeOf(c, scope)) === null;
4140
- const { storageStranded } = await reapScopeScriptCopies({ admin: c.var.admin, actor, resolveRef: options.resolveVerticalRef }, scope.tenantId, scope.id);
4141
- const bySlug = unrouted ? await viaDeployment() : false;
4361
+ const noScript = (await routeOf(c, scope)) === null;
4362
+ const { storageStranded } = await drainScopeScriptCopies({ admin: c.var.admin, actor, resolveRef: dispatch }, scope.tenantId, scope.id);
4363
+ const bySlug = noScript ? await viaDeployment() : false;
4142
4364
  return storageStranded || bySlug;
4143
4365
  };
4144
4366
  app.delete('/tenants/:tenantId/scopes/:scopeId', async (c) => {
@@ -4147,9 +4369,9 @@ export function createControlPlaneApi(options) {
4147
4369
  const actor = c.get('actor');
4148
4370
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
4149
4371
  if (!scope)
4150
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
4372
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4151
4373
  if (!scope.forkedFrom) {
4152
- return c.json({ error: `scope ${scopeId} is not a fork — only snapshots may be deleted` }, 409);
4374
+ return refuse(c, 'conflict', `scope ${scopeId} is not a fork — only snapshots may be deleted`);
4153
4375
  }
4154
4376
  try {
4155
4377
  // Vertical's storage first, then the in-process delete (refusal re-check,
@@ -4162,7 +4384,7 @@ export function createControlPlaneApi(options) {
4162
4384
  }
4163
4385
  catch (e) {
4164
4386
  if (e instanceof ControlPlaneError) {
4165
- return c.json({ error: e.message }, failedWith(c, e));
4387
+ return relayRefusal(c, e);
4166
4388
  }
4167
4389
  throw e;
4168
4390
  }
@@ -4211,12 +4433,12 @@ export function createControlPlaneApi(options) {
4211
4433
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
4212
4434
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
4213
4435
  if (!options.scopeBackups)
4214
- return c.json({ error: 'no backup target configured' }, 501);
4436
+ return refuseStatus(c, 501, 'no backup target configured');
4215
4437
  // Reads the directory, not the store, for the tenant cross-check: the store is keyed
4216
4438
  // by (tenant, scope) but nothing there proves the caller's tenant owns the scope.
4217
4439
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
4218
4440
  if (!scope)
4219
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
4441
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4220
4442
  return c.json(await options.scopeBackups.list({ tenantId, scopeId }));
4221
4443
  });
4222
4444
  // One backup's DUMP — the restore source. Staff-only like the export it came from
@@ -4227,13 +4449,13 @@ export function createControlPlaneApi(options) {
4227
4449
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
4228
4450
  const capturedAt = c.req.param('capturedAt');
4229
4451
  if (!options.scopeBackups)
4230
- return c.json({ error: 'no backup target configured' }, 501);
4452
+ return refuseStatus(c, 501, 'no backup target configured');
4231
4453
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
4232
4454
  if (!scope)
4233
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
4455
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4234
4456
  const dump = await options.scopeBackups.get({ tenantId, scopeId, capturedAt });
4235
4457
  if (!dump)
4236
- return c.json({ error: `no backup for scope ${scopeId} at ${capturedAt}` }, 404);
4458
+ return refuse(c, 'not_found', `no backup for scope ${scopeId} at ${capturedAt}`);
4237
4459
  // Unseal on the way out (#37). The bytes in the store stay sealed — this is the
4238
4460
  // authorized read opening them with the keys the platform holds. A subject shredded
4239
4461
  // since the copy was taken opens to a null payload, which is exactly the erasure
@@ -4250,13 +4472,13 @@ export function createControlPlaneApi(options) {
4250
4472
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
4251
4473
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
4252
4474
  if (!scope)
4253
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
4475
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4254
4476
  try {
4255
4477
  return c.json(await backupScope(c, tenantId, scope), 201);
4256
4478
  }
4257
4479
  catch (e) {
4258
4480
  if (e instanceof ControlPlaneError) {
4259
- return c.json({ error: e.message }, failedWith(c, e));
4481
+ return relayRefusal(c, e);
4260
4482
  }
4261
4483
  throw e;
4262
4484
  }
@@ -4284,7 +4506,7 @@ export function createControlPlaneApi(options) {
4284
4506
  const subjectId = dataSubjectIdSchema.parse(c.req.param('subjectId'));
4285
4507
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
4286
4508
  if (!scope)
4287
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
4509
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4288
4510
  const copies = await listAllScopeScriptCopies(c.var.admin, c.get('actor'), tenantId, scopeId);
4289
4511
  // A move in flight can still restore a copy no inventory read now would name. Its entries
4290
4512
  // settle when it confirms or fails, or, if it died, when the sweep finds its lease run out.
@@ -4293,17 +4515,33 @@ export function createControlPlaneApi(options) {
4293
4515
  }
4294
4516
  const erasureEpoch = await c.var.admin.scopeErasureEpoch(c.get('actor'), tenantId, scopeId);
4295
4517
  const currentRef = await routeOf(c, scope);
4518
+ // The version the deployment runs, read once (#2285): the stores below are judged against it,
4519
+ // and the claim before the key goes requires it, and the serving pointer as read, to still hold.
4520
+ const running = await runningVersionOf(c.var.admin, c.get('actor'), scope);
4296
4521
  // #2207: the stores of the version the deployment runs, resolved before anything is erased. A
4297
- // version that declares none is not asked. One that cannot be resolved while a kind is
4522
+ // version that declares none is not asked. One that cannot be resolved while a driver is
4298
4523
  // registered refuses (503): the key must not be destroyed while a store may hold the subject.
4299
- const reach = await platformStoresOf(c, scope);
4524
+ const reach = await platformStoresOf(c, scope, running);
4525
+ /**
4526
+ * L19: a bind, promote, rollback or serving re-upload that lands while the stores are erased
4527
+ * would run a version whose stores this erasure did not reach. Both arms take one atomic claim
4528
+ * on this fence before the key goes, and the claim moves the erasure epoch every bind compares,
4529
+ * so the bind either lands first and the claim refuses (412, the key kept), or it is refused.
4530
+ */
4531
+ const fence = {
4532
+ versionId: scope.verticalVersionId,
4533
+ servingRef: scope.servingRef ?? null,
4534
+ epoch: erasureEpoch,
4535
+ copyCount: copies.length,
4536
+ ...(running.servingPointer !== undefined ? { servingPointer: running.servingPointer } : {}),
4537
+ };
4300
4538
  // Not asked is `undefined`, not `[]` (#2242): with no platform path the host's own stores
4301
4539
  // (the pure adapter's co-located runners) are erased in its shred or its finalize.
4302
4540
  const eraseStores = async () => reach ? reach.caller.eraseSubject({ tenantId, scopeId, vertical: reach.vertical, subjectId, stores: reach.stores }) : undefined;
4303
4541
  /** The host's own shred, with the platform's store erasure first: a store that errors keeps the key. */
4304
4542
  const shredHere = async () => {
4305
4543
  const storeErasures = await eraseStores();
4306
- const receipt = await c.var.admin.shredSubject(c.get('actor'), tenantId, scopeId, subjectId);
4544
+ const receipt = await c.var.admin.shredSubject(c.get('actor'), tenantId, scopeId, subjectId, fence).catch(relayHostRefusal);
4307
4545
  return { ...receipt, storeErasures: [...receipt.storeErasures, ...(storeErasures ?? [])] };
4308
4546
  };
4309
4547
  if (!options.resolveVerticalRef) {
@@ -4332,10 +4570,7 @@ export function createControlPlaneApi(options) {
4332
4570
  if (bySlug)
4333
4571
  redactions.push(await bySlug.redactSubject(scopeId, subjectId));
4334
4572
  const storeErasures = await eraseStores();
4335
- return c.json(await c.var.admin.finalizeSubjectShred(c.get('actor'), tenantId, scopeId, subjectId, redactions, {
4336
- versionId: scope.verticalVersionId, servingRef: scope.servingRef ?? null, epoch: erasureEpoch,
4337
- copyCount: copies.length,
4338
- }, storeErasures).catch(relayHostRefusal));
4573
+ return c.json(await c.var.admin.finalizeSubjectShred(c.get('actor'), tenantId, scopeId, subjectId, redactions, fence, storeErasures).catch(relayHostRefusal));
4339
4574
  });
4340
4575
  // -- directory backups (#40) -----------------------------------------------
4341
4576
  // The platform's own disaster recovery, on the same store posture as the scope
@@ -4350,7 +4585,7 @@ export function createControlPlaneApi(options) {
4350
4585
  // question an unrehearsed backup story never has a way to ask.
4351
4586
  app.get('/directory/backups', async (c) => {
4352
4587
  if (!options.directoryBackups)
4353
- return c.json({ error: 'no directory backup target configured' }, 501);
4588
+ return refuseStatus(c, 501, 'no directory backup target configured');
4354
4589
  return c.json(await options.directoryBackups.list());
4355
4590
  });
4356
4591
  // Take one NOW, cadence ignored — the pre-migration checkpoint an operator takes by
@@ -4358,7 +4593,7 @@ export function createControlPlaneApi(options) {
4358
4593
  // copy without waiting a day for the cron.
4359
4594
  app.post('/directory/backups', async (c) => {
4360
4595
  if (!options.directoryBackups)
4361
- return c.json({ error: 'no directory backup target configured' }, 501);
4596
+ return refuseStatus(c, 501, 'no directory backup target configured');
4362
4597
  const result = await backupDirectoryIfDue({
4363
4598
  admin: c.var.admin,
4364
4599
  store: options.directoryBackups,
@@ -4373,11 +4608,11 @@ export function createControlPlaneApi(options) {
4373
4608
  // and audited by `exportDirectory` underneath.
4374
4609
  app.get('/directory/backups/:capturedAt', async (c) => {
4375
4610
  if (!options.directoryBackups)
4376
- return c.json({ error: 'no directory backup target configured' }, 501);
4611
+ return refuseStatus(c, 501, 'no directory backup target configured');
4377
4612
  const capturedAt = c.req.param('capturedAt');
4378
4613
  const dump = await options.directoryBackups.get({ capturedAt });
4379
4614
  if (!dump)
4380
- return c.json({ error: `no directory backup at ${capturedAt}` }, 404);
4615
+ return refuse(c, 'not_found', `no directory backup at ${capturedAt}`);
4381
4616
  return c.json(dump);
4382
4617
  });
4383
4618
  // Break-glass: REPLACE the directory with a stored copy.
@@ -4391,18 +4626,16 @@ export function createControlPlaneApi(options) {
4391
4626
  // with nothing in it — needs no such ceremony.
4392
4627
  app.post('/directory/restore', async (c) => {
4393
4628
  if (!options.directoryBackups)
4394
- return c.json({ error: 'no directory backup target configured' }, 501);
4629
+ return refuseStatus(c, 501, 'no directory backup target configured');
4395
4630
  const body = restoreDirectoryBody.parse(await c.req.json().catch(() => ({})));
4396
4631
  const dump = await options.directoryBackups.get({ capturedAt: body.capturedAt });
4397
4632
  if (!dump)
4398
- return c.json({ error: `no directory backup at ${body.capturedAt}` }, 404);
4633
+ return refuse(c, 'not_found', `no directory backup at ${body.capturedAt}`);
4399
4634
  const actor = c.get('actor');
4400
4635
  const live = await c.var.admin.listTenants(actor, { limit: 1 });
4401
4636
  if (live.length > 0 && !body.overwrite) {
4402
- return c.json({
4403
- error: 'the directory is not empty — a restore REPLACES it, so anything created ' +
4404
- 'since this copy was taken would be lost; pass overwrite=true to confirm',
4405
- }, 409);
4637
+ return refuse(c, 'conflict', 'the directory is not empty — a restore REPLACES it, so anything created ' +
4638
+ 'since this copy was taken would be lost; pass overwrite=true to confirm');
4406
4639
  }
4407
4640
  await c.var.admin.restoreDirectory(actor, dump);
4408
4641
  return c.json({ capturedAt: dump.capturedAt, tables: dump.tables.length });
@@ -4454,7 +4687,7 @@ export function createControlPlaneApi(options) {
4454
4687
  };
4455
4688
  app.post('/previews/repair-serving-pins', async (c) => {
4456
4689
  if (!options.resolveVerticalVersion || !options.resolveVerticalRef) {
4457
- return c.json({ error: 'repairing serving pins needs dispatch resolution for both ends' }, 501);
4690
+ return refuseStatus(c, 501, 'repairing serving pins needs dispatch resolution for both ends');
4458
4691
  }
4459
4692
  const { cursor, limit, dryRun } = await readJsonBody(c, repairServingPinsBody);
4460
4693
  const actor = c.get('actor');
@@ -4625,13 +4858,13 @@ export function createControlPlaneApi(options) {
4625
4858
  const actor = c.get('actor');
4626
4859
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
4627
4860
  if (!scope)
4628
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
4861
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4629
4862
  if (!isPrimaryScope(scope)) {
4630
- return c.json({ error: 'clear-copy-mark refused: the directory says this scope is a copy (a preview or a fork)' }, 409);
4863
+ return refuse(c, 'conflict', 'clear-copy-mark refused: the directory says this scope is a copy (a preview or a fork)');
4631
4864
  }
4632
4865
  const vertical = await verticalForScope(c, scope);
4633
4866
  if (!vertical)
4634
- return c.json({ error: await diagnoseUnboundScope(actor, scope) }, 501);
4867
+ return refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope));
4635
4868
  const { cleared } = await vertical.clearCopyMark(scopeId, { kind: scope.kind, forkedFrom: scope.forkedFrom });
4636
4869
  await c.var.admin.recordCopyMark(actor, { action: 'clear', tenantId, scopeId, outcome: cleared ? 'cleared' : 'absent' });
4637
4870
  return c.json({ cleared });
@@ -4657,16 +4890,14 @@ export function createControlPlaneApi(options) {
4657
4890
  const actor = c.get('actor');
4658
4891
  const parsed = redrainEventsInput.safeParse(await c.req.json().catch(() => ({})));
4659
4892
  if (!parsed.success) {
4660
- return c.json({ error: 'body must be { drainedBefore: <ISO 8601 instant> } — the end of the window to reopen' }, 400);
4893
+ return refuse(c, 'validation_failed', 'body must be { drainedBefore: <ISO 8601 instant> } — the end of the window to reopen');
4661
4894
  }
4662
4895
  // Refused at the door rather than left to the adapters: an instant in the future
4663
4896
  // reopens rows the drain shipped AFTER the rebuild, which is exactly the double-write
4664
4897
  // the required instant exists to prevent. Host code, so it may read the real clock.
4665
4898
  if (Date.parse(parsed.data.drainedBefore) > Date.now()) {
4666
- return c.json({
4667
- error: `drainedBefore ${parsed.data.drainedBefore} is in the future — it would reopen rows ` +
4668
- 'already shipped to the rebuilt table. Pass the instant the old table stopped receiving.',
4669
- }, 400);
4899
+ return refuse(c, 'validation_failed', `drainedBefore ${parsed.data.drainedBefore} is in the future — it would reopen rows ` +
4900
+ 'already shipped to the rebuilt table. Pass the instant the old table stopped receiving.');
4670
4901
  }
4671
4902
  // The input schema also carries `countOnly` (#1545), and this route does not honour it:
4672
4903
  // its answer (`redrained`, `more`) is a statement that rows moved. Ignoring the field
@@ -4675,11 +4906,11 @@ export function createControlPlaneApi(options) {
4675
4906
  // caller is told where counting lives. Refused whatever its value: on this route the
4676
4907
  // field has no meaning at all, and "accepted when false" is a rule nobody can rely on.
4677
4908
  if (parsed.data.countOnly !== undefined) {
4678
- return c.json({ error: 'countOnly is not honoured here — POST the same body to …/redrain-count for a read-only count' }, 400);
4909
+ return refuse(c, 'validation_failed', 'countOnly is not honoured here — POST the same body to …/redrain-count for a read-only count');
4679
4910
  }
4680
4911
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
4681
4912
  if (!scope)
4682
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
4913
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4683
4914
  // The instant alone, never `parsed.data`, so the field cannot reach the adapter from
4684
4915
  // this door even if the refusal above is ever relaxed.
4685
4916
  const redrained = await c.var.admin.redrainEvents(actor, tenantId, scopeId, {
@@ -4710,26 +4941,72 @@ export function createControlPlaneApi(options) {
4710
4941
  const actor = c.get('actor');
4711
4942
  const parsed = redrainEventsInput.safeParse(await c.req.json().catch(() => ({})));
4712
4943
  if (!parsed.success) {
4713
- return c.json({ error: 'body must be { drainedBefore: <ISO 8601 instant> } — the window to count' }, 400);
4944
+ return refuse(c, 'validation_failed', 'body must be { drainedBefore: <ISO 8601 instant> } — the window to count');
4714
4945
  }
4715
4946
  // The same future refusal as the reopen. A count of a window that has not closed yet
4716
4947
  // is not dangerous, it is meaningless — and a dry run whose instant this door accepts
4717
4948
  // and the real run rejects would be the worst kind of rehearsal.
4718
4949
  if (Date.parse(parsed.data.drainedBefore) > Date.now()) {
4719
- return c.json({
4720
- error: `drainedBefore ${parsed.data.drainedBefore} is in the future — it would count rows ` +
4721
- 'already shipped to the rebuilt table. Pass the instant the old table stopped receiving.',
4722
- }, 400);
4950
+ return refuse(c, 'validation_failed', `drainedBefore ${parsed.data.drainedBefore} is in the future — it would count rows ` +
4951
+ 'already shipped to the rebuilt table. Pass the instant the old table stopped receiving.');
4723
4952
  }
4724
4953
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
4725
4954
  if (!scope)
4726
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
4955
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4727
4956
  const redrainable = await c.var.admin.redrainEvents(actor, tenantId, scopeId, {
4728
4957
  drainedBefore: parsed.data.drainedBefore,
4729
4958
  countOnly: true,
4730
4959
  });
4731
4960
  return c.json({ redrainable, drainedBefore: parsed.data.drainedBefore });
4732
4961
  });
4962
+ // #1334 — one tenant's events from the Tier-2 lake, through the gateway that scopes them
4963
+ // (`lake-gateway.ts`): the tenant is this path segment and nothing else, the filters are a
4964
+ // closed `.strict()` list with no tenant in it, and a row of any other tenant fails the
4965
+ // read whole. Envelope only — no payload until erasure reaches the lake.
4966
+ //
4967
+ // Staff only, by default-deny: on neither BUILDER_ROUTES nor TENANT_ROUTES. A tenant's
4968
+ // own credential reaching this is a separate decision, with a dashboard call site and a
4969
+ // member permission in front of it.
4970
+ //
4971
+ // K-24: the access row is written AFTER the engine answers and BEFORE anything is
4972
+ // returned, and a failed write fails the request — rows whose disclosure went unrecorded
4973
+ // are what the access log exists to prevent. A refused or failed query discloses nothing
4974
+ // and leaves no row.
4975
+ app.get('/tenants/:tenantId/lake/events', async (c) => {
4976
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
4977
+ if (!options.lakeQuery) {
4978
+ return refuseStatus(c, 501, 'the Tier-2 lake is not queryable on this control plane');
4979
+ }
4980
+ const parsed = lakeEventsInput.safeParse(c.req.query());
4981
+ if (!parsed.success) {
4982
+ // `errors` is the declared member (#113); `issues`, zod's own shape, is what this route
4983
+ // answered before it and stays for the clients that read it. Carried rather than built by
4984
+ // `coded`, which would answer a field list with its canonical sentence instead of this one.
4985
+ return refuse(c, 'validation_failed', 'invalid lake query', {
4986
+ errors: validationIssuesFrom(parsed.error),
4987
+ issues: parsed.error.issues.map((i) => ({ path: i.path, message: i.message })),
4988
+ });
4989
+ }
4990
+ let page;
4991
+ try {
4992
+ page = await queryLakeEvents(options.lakeQuery, tenantId, parsed.data);
4993
+ }
4994
+ catch (err) {
4995
+ if (err instanceof LakeQueryError)
4996
+ return refuseStatus(c, 502, err.message);
4997
+ throw err;
4998
+ }
4999
+ // The filters, not the page: the same projection every other read logs.
5000
+ const { cursor: _cursor, limit: _limit, ...filters } = parsed.data;
5001
+ await c.var.admin.recordDelegatedRead(c.get('actor'), {
5002
+ method: 'lakeEvents',
5003
+ tenantId,
5004
+ scopeId: parsed.data.scopeId ?? null,
5005
+ params: filters,
5006
+ resultCount: page.events.length,
5007
+ });
5008
+ return c.json(page);
5009
+ });
4733
5010
  app.post('/tenants/:tenantId/scopes/:scopeId/reap', async (c) => {
4734
5011
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
4735
5012
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
@@ -4739,9 +5016,9 @@ export function createControlPlaneApi(options) {
4739
5016
  const { backup: wantsBackup } = await readJsonBody(c, reapScopeBody);
4740
5017
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
4741
5018
  if (!scope)
4742
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
5019
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4743
5020
  if (scope.status !== 'archived') {
4744
- return c.json({ error: `scope ${scopeId} is ${scope.status}, not archived — only an archived scope may be reaped` }, 409);
5021
+ return refuse(c, 'conflict', `scope ${scopeId} is ${scope.status}, not archived — only an archived scope may be reaped`);
4745
5022
  }
4746
5023
  // Refuse a scope that still resolves a hostname BEFORE any delegation — the vertical's
4747
5024
  // `deleteScope` below wipes the hosted DO's bytes, so this must gate ahead of it, not
@@ -4750,10 +5027,8 @@ export function createControlPlaneApi(options) {
4750
5027
  // the same guard because releasing every name is the point there.)
4751
5028
  const boundNames = await c.var.admin.listHostnames(actor, { scopeId, limit: 1 });
4752
5029
  if (boundNames.length > 0) {
4753
- return c.json({
4754
- error: `scope ${scopeId} still resolves hostname '${boundNames[0].hostname}' — ` +
4755
- `unbind it before reaping (reap wipes storage and cannot be undone)`,
4756
- }, 409);
5030
+ return refuse(c, 'conflict', `scope ${scopeId} still resolves hostname '${boundNames[0].hostname}' — ` +
5031
+ `unbind it before reaping (reap wipes storage and cannot be undone)`);
4757
5032
  }
4758
5033
  // The recoverable copy, BEFORE any byte is wiped (#493). Ordering is the whole
4759
5034
  // guarantee: `backupScope` has to have resolved — durably stored — before
@@ -4772,13 +5047,10 @@ export function createControlPlaneApi(options) {
4772
5047
  }
4773
5048
  catch (e) {
4774
5049
  if (e instanceof ControlPlaneError) {
4775
- return c.json({ error: e.message }, failedWith(c, e));
5050
+ return relayRefusal(c, e);
4776
5051
  }
4777
- return c.json({
4778
- error: 'backup failed — the scope was NOT reaped and its data is intact; ' +
4779
- 'retry, or reap with backup=false to accept an unrecoverable wipe',
4780
- detail: e instanceof Error ? e.message : String(e),
4781
- }, 502);
5052
+ return refuseStatus(c, 502, 'backup failed — the scope was NOT reaped and its data is intact; ' +
5053
+ 'retry, or reap with backup=false to accept an unrecoverable wipe', { detail: e instanceof Error ? e.message : String(e) });
4782
5054
  }
4783
5055
  }
4784
5056
  try {
@@ -4793,7 +5065,7 @@ export function createControlPlaneApi(options) {
4793
5065
  }
4794
5066
  catch (e) {
4795
5067
  if (e instanceof ControlPlaneError) {
4796
- return c.json({ error: e.message }, failedWith(c, e));
5068
+ return relayRefusal(c, e);
4797
5069
  }
4798
5070
  throw e;
4799
5071
  }
@@ -4810,15 +5082,13 @@ export function createControlPlaneApi(options) {
4810
5082
  const actor = c.get('actor');
4811
5083
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
4812
5084
  if (!scope)
4813
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
5085
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4814
5086
  // Residency (K-7/K-32): jurisdiction pins EXECUTION, not just storage. A pull
4815
5087
  // lands the data on a machine outside the platform's control, so anything
4816
5088
  // pinned tighter than `global` is refused until a compliant path exists.
4817
5089
  if (scope.jurisdiction !== 'global') {
4818
- return c.json({
4819
- error: `scope ${scopeId} is pinned to '${scope.jurisdiction}' — a local pull would ` +
4820
- `move its data outside that jurisdiction; refused (K-32, preview-and-snapshots.md §6)`,
4821
- }, 403);
5090
+ return refuse(c, 'forbidden', `scope ${scopeId} is pinned to '${scope.jurisdiction}' — a local pull would ` +
5091
+ `move its data outside that jurisdiction; refused (K-32, preview-and-snapshots.md §6)`);
4822
5092
  }
4823
5093
  const full = c.req.query('full') === 'true';
4824
5094
  try {
@@ -4846,7 +5116,7 @@ export function createControlPlaneApi(options) {
4846
5116
  }
4847
5117
  catch (e) {
4848
5118
  if (e instanceof ControlPlaneError) {
4849
- return c.json({ error: e.message }, failedWith(c, e));
5119
+ return relayRefusal(c, e);
4850
5120
  }
4851
5121
  throw e;
4852
5122
  }
@@ -4873,7 +5143,7 @@ export function createControlPlaneApi(options) {
4873
5143
  const actor = c.get('actor');
4874
5144
  const tenant = await c.var.admin.getTenant(actor, tenantId);
4875
5145
  if (!tenant)
4876
- return c.json({ error: `unknown tenant: ${tenantId}` }, 404);
5146
+ return refuse(c, 'not_found', `unknown tenant: ${tenantId}`);
4877
5147
  // Every scope, tombstones included: an archived or reaped scope is part of the
4878
5148
  // tenant's history, and an export that quietly dropped them would misrepresent what
4879
5149
  // the tenant was. Their DATA is a different question, handled below.
@@ -4885,12 +5155,10 @@ export function createControlPlaneApi(options) {
4885
5155
  // partial is the failure mode worth avoiding.
4886
5156
  const pinned = scopes.filter((s) => s.jurisdiction !== 'global');
4887
5157
  if (pinned.length > 0) {
4888
- return c.json({
4889
- error: `tenant ${tenantId} has ${pinned.length} scope(s) pinned to a jurisdiction ` +
4890
- `(${[...new Set(pinned.map((s) => s.jurisdiction))].join(', ')}) — an export would ` +
4891
- `move that data out of the region it was promised; refused (K-32). Export the ` +
4892
- `global scopes individually, or wait for a per-jurisdiction path.`,
4893
- }, 403);
5158
+ return refuse(c, 'forbidden', `tenant ${tenantId} has ${pinned.length} scope(s) pinned to a jurisdiction ` +
5159
+ `(${[...new Set(pinned.map((s) => s.jurisdiction))].join(', ')}) — an export would ` +
5160
+ `move that data out of the region it was promised; refused (K-32). Export the ` +
5161
+ `global scopes individually, or wait for a per-jurisdiction path.`);
4894
5162
  }
4895
5163
  const full = c.req.query('full') === 'true';
4896
5164
  try {
@@ -4969,7 +5237,7 @@ export function createControlPlaneApi(options) {
4969
5237
  }
4970
5238
  catch (e) {
4971
5239
  if (e instanceof ControlPlaneError) {
4972
- return c.json({ error: e.message }, failedWith(c, e));
5240
+ return relayRefusal(c, e);
4973
5241
  }
4974
5242
  throw e;
4975
5243
  }
@@ -4991,7 +5259,7 @@ export function createControlPlaneApi(options) {
4991
5259
  const actor = c.get('actor');
4992
5260
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
4993
5261
  if (!scope)
4994
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
5262
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4995
5263
  const raw = await c.req.json();
4996
5264
  const dump = scopeDump.parse(raw);
4997
5265
  // #1869: where the uploaded file says it came from, when the caller had to set the dump's
@@ -5001,7 +5269,7 @@ export function createControlPlaneApi(options) {
5001
5269
  // A backup with no tables is not a scope dump — name that plainly rather than letting
5002
5270
  // the empty replay reach the checker and surface as a bare `internal error` (#321).
5003
5271
  if (dump.tables.length === 0) {
5004
- return c.json({ error: 'restore refused: the backup has no tables — not a scope dump' }, 422);
5272
+ return refuseStatus(c, 422, 'restore refused: the backup has no tables — not a scope dump');
5005
5273
  }
5006
5274
  // #2207: the dump's stores against the destination's declaration, before anything is written.
5007
5275
  if (options.platformScopeStores) {
@@ -5010,7 +5278,7 @@ export function createControlPlaneApi(options) {
5010
5278
  }
5011
5279
  catch (e) {
5012
5280
  if (e instanceof ControlPlaneError)
5013
- return c.json({ error: e.message }, failedWith(c, e));
5281
+ return relayRefusal(c, e);
5014
5282
  throw e;
5015
5283
  }
5016
5284
  }
@@ -5076,7 +5344,7 @@ export function createControlPlaneApi(options) {
5076
5344
  // reconcile); a clear that fails here must not hide the restore's own error.
5077
5345
  await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null).catch(() => undefined);
5078
5346
  if (e instanceof ControlPlaneError) {
5079
- return c.json({ error: e.message }, failedWith(c, e));
5347
+ return relayRefusal(c, e);
5080
5348
  }
5081
5349
  // A restore throw is driven by the caller-supplied dump (a shape the target cannot
5082
5350
  // load, a DDL the engine rejects), so DISCLOSE it as an actionable 422 rather than
@@ -5084,7 +5352,7 @@ export function createControlPlaneApi(options) {
5084
5352
  // secondary obs #2). The route is staff/owner-gated and the detail is about the
5085
5353
  // dump the caller sent, not another tenant's state.
5086
5354
  const detail = e instanceof Error ? e.message : String(e);
5087
- return c.json({ error: 'restore failed — the backup could not be loaded', detail }, 422);
5355
+ return refuseStatus(c, 422, 'restore failed — the backup could not be loaded', { detail });
5088
5356
  }
5089
5357
  });
5090
5358
  // #1236: when this scope's migrations actually ran — the schema-change history
@@ -5097,7 +5365,7 @@ export function createControlPlaneApi(options) {
5097
5365
  const actor = c.get('actor');
5098
5366
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
5099
5367
  if (!scope)
5100
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
5368
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5101
5369
  const vertical = await verticalForScope(c, scope);
5102
5370
  return c.json(vertical
5103
5371
  ? await vertical.appliedMigrations(scopeId)
@@ -5113,7 +5381,7 @@ export function createControlPlaneApi(options) {
5113
5381
  const actor = c.get('actor');
5114
5382
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
5115
5383
  if (!scope)
5116
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
5384
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5117
5385
  const vertical = await verticalForScope(c, scope);
5118
5386
  return c.json(vertical
5119
5387
  ? await vertical.migrationBookmarks(scopeId)
@@ -5133,14 +5401,14 @@ export function createControlPlaneApi(options) {
5133
5401
  const actor = c.get('actor');
5134
5402
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
5135
5403
  if (!scope)
5136
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
5404
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5137
5405
  try {
5138
5406
  const vertical = await verticalForScope(c, scope);
5139
5407
  const result = await c.var.admin.rewindScope(actor, tenantId, scopeId, bookmark, {
5140
5408
  force,
5141
5409
  localApply: !vertical,
5142
5410
  });
5143
- const answer = vertical ? await vertical.rewindScope(scopeId, bookmark, { force }) : result;
5411
+ const answer = vertical ? await vertical.rewindScope(scopeId, bookmark, { force, tenantId }) : result;
5144
5412
  // #1674: a rewind takes the scope's grants back to the bookmark, and with them any
5145
5413
  // schedule kill switch pulled since — a tenant owner could otherwise undo a staff
5146
5414
  // switch this way. Clearing the provisioned receipt, only once the rewind has gone
@@ -5156,7 +5424,7 @@ export function createControlPlaneApi(options) {
5156
5424
  }
5157
5425
  catch (e) {
5158
5426
  if (e instanceof ControlPlaneError) {
5159
- return c.json({ error: e.message }, failedWith(c, e));
5427
+ return relayRefusal(c, e);
5160
5428
  }
5161
5429
  throw e;
5162
5430
  }
@@ -5176,7 +5444,7 @@ export function createControlPlaneApi(options) {
5176
5444
  }
5177
5445
  catch (e) {
5178
5446
  if (e instanceof ControlPlaneError) {
5179
- return c.json(exportBreakBody(e), failedWith(c, e));
5447
+ return relayRefusal(c, e, exportBreaksOf(e));
5180
5448
  }
5181
5449
  throw e;
5182
5450
  }
@@ -5194,13 +5462,13 @@ export function createControlPlaneApi(options) {
5194
5462
  if (adoptPin) {
5195
5463
  const v = await verticalOf(actor, slug);
5196
5464
  if (!v || v.ownerTenant !== adoptPin)
5197
- return c.json({ error: 'forbidden' }, 403);
5465
+ return refuse(c, 'forbidden', 'forbidden');
5198
5466
  }
5199
5467
  const v = await verticalOf(actor, slug);
5200
5468
  if (!v)
5201
- return c.json({ error: `unknown vertical '${slug}'` }, 404);
5469
+ return refuse(c, 'not_found', `unknown vertical '${slug}'`);
5202
5470
  if (v.ownerTenant === null) {
5203
- return c.json({ error: 'adopt-serving is a private-vertical operation' }, 409);
5471
+ return refuse(c, 'conflict', 'adopt-serving is a private-vertical operation');
5204
5472
  }
5205
5473
  const owned = (await c.var.admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug })).filter((s) => !s.forkedFrom && s.kind !== 'preview' && s.status === 'active');
5206
5474
  const { acknowledge } = await readJsonBody(c, adoptVerticalServingBody);
@@ -5214,17 +5482,17 @@ export function createControlPlaneApi(options) {
5214
5482
  }
5215
5483
  catch (e) {
5216
5484
  if (e instanceof ControlPlaneError) {
5217
- return c.json({ ...exportBreakBody(e), adopted, alreadyAdopted }, failedWith(c, e));
5485
+ return relayRefusal(c, e, { ...exportBreaksOf(e), adopted, alreadyAdopted });
5218
5486
  }
5219
5487
  throw e;
5220
5488
  }
5221
5489
  return c.json({ vertical: slug, adopted, alreadyAdopted });
5222
5490
  });
5223
5491
  // Rebind ONE scope onto a different vertical lineage (#389) — the update-rebind that
5224
- // retires a platform-owned lineage in favour of a tenant-owned one. Deliberately NOT
5225
- // in BUILDER_ROUTES: a lineage crossing re-homes a customer's data under a different
5226
- // registry owner, which is a staff decision like admission — the allowlist's
5227
- // default-deny 403s a builder without a line here.
5492
+ // retires a platform-owned lineage in favour of a tenant-owned one. Callers: staff, and a
5493
+ // tenant credential pinned to the tenant in the path (TENANT_ROUTES); the crossing is judged
5494
+ // by `crossingHidden`, the rule the review read shares. Deliberately NOT in BUILDER_ROUTES:
5495
+ // a builder's push token is default-denied it.
5228
5496
  app.post('/tenants/:tenantId/scopes/:scopeId/rebind-vertical', async (c) => {
5229
5497
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
5230
5498
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
@@ -5232,10 +5500,11 @@ export function createControlPlaneApi(options) {
5232
5500
  // #977: the TARGET must be a vertical this principal can read — its own tenant's, or a
5233
5501
  // listed one. The path pin confines whose scope moves; without this, it could move onto
5234
5502
  // another tenant's private vertical, which a confined caller must not even learn exists
5235
- // (404, as `GET /verticals/:slug` answers it).
5236
- if (await notReadable(c.get('principal'), vertical)) {
5237
- return c.json({ error: `unknown vertical: ${vertical}` }, 404);
5238
- }
5503
+ // (404, as `GET /verticals/:slug` answers it). #2347: the one rule its review read shares.
5504
+ const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
5505
+ // An unknown scope answers the same bytes as a hidden crossing, so the two cannot be told apart.
5506
+ if (!scope || (await crossingHidden(c.get('principal'), scope.vertical ?? null, vertical)))
5507
+ return crossingNotFound(c);
5239
5508
  try {
5240
5509
  const r = await rebindScopeOntoVertical(c, tenantId, scopeId, vertical, {
5241
5510
  ackMigrations,
@@ -5246,11 +5515,60 @@ export function createControlPlaneApi(options) {
5246
5515
  }
5247
5516
  catch (e) {
5248
5517
  if (e instanceof ControlPlaneError) {
5249
- return c.json(exportBreakBody(e), failedWith(c, e));
5518
+ return relayRefusal(c, e, exportBreaksOf(e));
5250
5519
  }
5251
5520
  throw e;
5252
5521
  }
5253
5522
  });
5523
+ // #2347: the review a rebind's migration acknowledgement answers — what the target lineage's
5524
+ // serving version adds over the version the scope is bound to, with the binding
5525
+ // (`acknowledge.review`) computed from the very rows this answer shows. The server picks both
5526
+ // sides, so a caller cannot bind a review of a base the scope is not on.
5527
+ //
5528
+ // Who may read it is exactly who may make the rebind it answers (`crossingHidden`): a confined
5529
+ // caller must be able to read both verticals, its own tenant's or a listed one. An unknown
5530
+ // scope, a scope with no vertical, an unknown target and a vertical the caller cannot read all
5531
+ // answer the same 404, so the read is no oracle for another tenant's verticals.
5532
+ // Callers: staff, and a tenant credential pinned to the tenant in the path, as the rebind.
5533
+ //
5534
+ // The migration SQL itself stays owner-only, as `/versions/:id/migrations` keeps it: it describes
5535
+ // a schema. A caller that does not own both verticals gets `migrations: null` and
5536
+ // `migrationsWithheld: true`, with everything else: both sides, whether the digests differ, and
5537
+ // the binding, so it can still acknowledge the review and rebind onto a listed vertical.
5538
+ app.get('/tenants/:tenantId/scopes/:scopeId/rebind-review', async (c) => {
5539
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
5540
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
5541
+ const target = z.string().min(1).parse(c.req.query('vertical'));
5542
+ const actor = c.get('actor');
5543
+ const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
5544
+ if (!scope?.vertical || (await crossingHidden(c.get('principal'), scope.vertical, target)))
5545
+ return crossingNotFound(c);
5546
+ const serving = await c.var.admin.verticalServing(actor, target).catch((e) => {
5547
+ if (errorCodeOf(e) === 'not_found')
5548
+ return undefined;
5549
+ throw e;
5550
+ });
5551
+ if (serving === undefined)
5552
+ return crossingNotFound(c);
5553
+ if (serving === null) {
5554
+ return refuseStatus(c, 409, `vertical '${target}' has no serving script yet — promote a version to prod first`);
5555
+ }
5556
+ const { base, target: incoming } = await rebindSidesOf(c, scope, target, serving.versionId);
5557
+ const p = c.get('principal');
5558
+ const withheld = (await notOwned(p, scope.vertical)) || (await notOwned(p, target));
5559
+ const review = {
5560
+ base: scope.verticalVersionId ?? null,
5561
+ digest: await rebindReviewDigest(base && { vertical: scope.vertical, version: base }, { vertical: target, version: incoming }),
5562
+ };
5563
+ return c.json({
5564
+ base: scope.verticalVersionId ? { vertical: scope.vertical, versionId: scope.verticalVersionId } : null,
5565
+ target: { vertical: target, versionId: incoming.id },
5566
+ digestsMatch: Boolean(base && base.migrationDigest === incoming.migrationDigest),
5567
+ migrations: withheld || incoming.migrations === null ? null : migrationsOnTop(incoming.migrations, base ? base.migrations : undefined),
5568
+ migrationsWithheld: withheld,
5569
+ review,
5570
+ });
5571
+ });
5254
5572
  // #1756: which apps in this tenant binding `versionId` onto the scope would break — the
5255
5573
  // question the bind below refuses on, as a read, so a client can ask it before it records
5256
5574
  // anything of its own (the dashboard's Activity line) and show what an acknowledgement would
@@ -5317,7 +5635,7 @@ export function createControlPlaneApi(options) {
5317
5635
  const { script } = keptScriptQuery.parse(c.req.query());
5318
5636
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
5319
5637
  if (!scope)
5320
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
5638
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5321
5639
  const kept = await (await keptHolder(script)).keptCopy(scopeId);
5322
5640
  return c.json({ scopeId, script, kept, routesTo: await routeOf(c, scope) });
5323
5641
  });
@@ -5336,17 +5654,15 @@ export function createControlPlaneApi(options) {
5336
5654
  const actor = c.get('actor');
5337
5655
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
5338
5656
  if (!scope)
5339
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
5657
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5340
5658
  if (scope.jurisdiction !== 'global') {
5341
- return c.json({
5342
- error: `scope ${scopeId} is pinned to '${scope.jurisdiction}' — a local pull would ` +
5343
- `move its data outside that jurisdiction; refused (K-32, preview-and-snapshots.md §6)`,
5344
- }, 403);
5659
+ return refuse(c, 'forbidden', `scope ${scopeId} is pinned to '${scope.jurisdiction}' — a local pull would ` +
5660
+ `move its data outside that jurisdiction; refused (K-32, preview-and-snapshots.md §6)`);
5345
5661
  }
5346
5662
  const holder = await keptHolder(script);
5347
5663
  const kept = await holder.keptCopy(scopeId);
5348
5664
  if (!kept)
5349
- return c.json({ error: `scope ${scopeId} has no kept copy in '${script}'` }, 409);
5665
+ return refuse(c, 'conflict', `scope ${scopeId} has no kept copy in '${script}'`);
5350
5666
  const dump = await c.var.admin.exportScope(actor, tenantId, scopeId);
5351
5667
  const tables = await holder.exportScope(scopeId);
5352
5668
  if (full === 'true')
@@ -5361,20 +5677,18 @@ export function createControlPlaneApi(options) {
5361
5677
  const actor = c.get('actor');
5362
5678
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
5363
5679
  if (!scope)
5364
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
5680
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5365
5681
  const holder = await keptHolder(body.script);
5366
5682
  const kept = await holder.keptCopy(scopeId);
5367
5683
  if (!kept)
5368
- return c.json({ error: `scope ${scopeId} has no kept copy in '${body.script}'` }, 409);
5684
+ return refuse(c, 'conflict', `scope ${scopeId} has no kept copy in '${body.script}'`);
5369
5685
  const route = await routeOf(c, scope);
5370
5686
  // `release` is for a kept copy the scope routes to (a rollback bound it after a carry kept
5371
5687
  // it); the other two act on a kept copy the scope has left, against the store it runs on.
5372
5688
  if (body.action === 'release' ? route !== body.script : !route || route === body.script) {
5373
- return c.json({
5374
- error: body.action === 'release'
5375
- ? `scope ${scopeId} routes to '${route ?? '(nothing)'}', not to the kept copy in '${body.script}'; release is for a kept copy that is live`
5376
- : `scope ${scopeId} routes to '${route ?? '(nothing)'}', so there is no live copy to resolve against`,
5377
- }, 409);
5689
+ return refuse(c, 'conflict', body.action === 'release'
5690
+ ? `scope ${scopeId} routes to '${route ?? '(nothing)'}', not to the kept copy in '${body.script}'; release is for a kept copy that is live`
5691
+ : `scope ${scopeId} routes to '${route ?? '(nothing)'}', so there is no live copy to resolve against`);
5378
5692
  }
5379
5693
  const at = new Date().toISOString();
5380
5694
  const revisionOf = async (client) => {
@@ -5405,7 +5719,7 @@ export function createControlPlaneApi(options) {
5405
5719
  if (body.action === 'release') {
5406
5720
  const out = await holder.releaseKeptCopy({ scopeId, revision: revisionBefore, ...markCopy, ...stampFence });
5407
5721
  if ('refused' in out) {
5408
- return c.json({ error: `the kept copy of scope ${scopeId} ${out.refused === 'changed' ? 'changed since it was read' : 'is no longer kept'}; nothing was released` }, 412);
5722
+ return refuse(c, 'precondition_failed', `the kept copy of scope ${scopeId} ${out.refused === 'changed' ? 'changed since it was read' : 'is no longer kept'}; nothing was released`);
5409
5723
  }
5410
5724
  await record('release', revisionBefore);
5411
5725
  return c.json({ scopeId, script: body.script, action: 'release', revisionBefore });
@@ -5413,7 +5727,7 @@ export function createControlPlaneApi(options) {
5413
5727
  if (body.action === 'discard') {
5414
5728
  const out = await holder.discardKeptCopy({ scopeId, revision: revisionBefore, carriedTo: route, at, ...markCopy, ...stampFence });
5415
5729
  if ('refused' in out) {
5416
- return c.json({ error: `the kept copy of scope ${scopeId} ${out.refused === 'changed' ? 'changed since it was read' : 'is no longer kept'}; nothing was discarded` }, 412);
5730
+ return refuse(c, 'precondition_failed', `the kept copy of scope ${scopeId} ${out.refused === 'changed' ? 'changed since it was read' : 'is no longer kept'}; nothing was discarded`);
5417
5731
  }
5418
5732
  const revisionAfter = await revisionOf(holder);
5419
5733
  await record('discard', revisionAfter);
@@ -5421,10 +5735,8 @@ export function createControlPlaneApi(options) {
5421
5735
  }
5422
5736
  // restore-forward: the acknowledgement names what is replaced, as the marker says it.
5423
5737
  if (body.acknowledge.replacesLiveWritesSince !== kept.keptAt) {
5424
- return c.json({
5425
- error: `a restore forward replaces what the live store took since ${kept.keptAt}; acknowledge exactly that ` +
5426
- `(acknowledge.replacesLiveWritesSince: '${kept.keptAt}')`,
5427
- }, 409);
5738
+ return refuse(c, 'conflict', `a restore forward replaces what the live store took since ${kept.keptAt}; acknowledge exactly that ` +
5739
+ `(acknowledge.replacesLiveWritesSince: '${kept.keptAt}')`);
5428
5740
  }
5429
5741
  // Fenced on the binding through the whole resolution (Codex #2008 r8): the kept copy goes only
5430
5742
  // once its data is in the store the scope still routes to. Every refusal below leaves it kept.
@@ -5436,21 +5748,19 @@ export function createControlPlaneApi(options) {
5436
5748
  (now.verticalVersionId ?? null) === (scope.verticalVersionId ?? null) &&
5437
5749
  (now.servingRef ?? null) === (scope.servingRef ?? null));
5438
5750
  };
5439
- const moved = () => c.json({
5440
- error: `scope ${scopeId} was re-pointed while the kept copy was being restored forward, so nothing was resolved ` +
5441
- `and the kept copy in '${body.script}' stays kept; read it again and resolve it again`,
5442
- }, 409);
5751
+ const moved = () => refuse(c, 'conflict', `scope ${scopeId} was re-pointed while the kept copy was being restored forward, so nothing was resolved ` +
5752
+ `and the kept copy in '${body.script}' stays kept; read it again and resolve it again`);
5443
5753
  // The live store as read now, and nothing else, is what gets replaced. The binding read again
5444
5754
  // after the marker ties the marker to the live store: a move that landed before this read
5445
5755
  // (its wipe minting a tombstone here) changed the binding, and is refused.
5446
5756
  const liveMarker = await live.loadMarker(scopeId);
5447
5757
  if (liveMarker === 'unfenced') {
5448
- return c.json({ error: `the deployment serving scope ${scopeId} ('${liveScript}') predates the fence (#1722); re-push it, then resolve` }, 409);
5758
+ return refuse(c, 'conflict', `the deployment serving scope ${scopeId} ('${liveScript}') predates the fence (#1722); re-push it, then resolve`);
5449
5759
  }
5450
5760
  if (!(await bindingHeld()))
5451
5761
  return moved();
5452
5762
  if ((await isCarriedAway(live, scope)) || (await live.keptCopy(scopeId))) {
5453
- return c.json({ error: `the store scope ${scopeId} routes to in '${liveScript}' is not a live store (wiped or kept); nothing was resolved` }, 409);
5763
+ return refuse(c, 'conflict', `the store scope ${scopeId} routes to in '${liveScript}' is not a live store (wiped or kept); nothing was resolved`);
5454
5764
  }
5455
5765
  const dump = await retryTransient(() => holder.exportScope(scopeId));
5456
5766
  // #2185: before the load as well as after it, so a throw once the kept copy has landed (the
@@ -5489,10 +5799,8 @@ export function createControlPlaneApi(options) {
5489
5799
  ...stampFence,
5490
5800
  });
5491
5801
  if ('refused' in wiped) {
5492
- return c.json({
5493
- error: `restored forward into '${route}', but the kept copy in '${body.script}' changed since it was read, so it ` +
5494
- `stays kept: read it again and resolve it again`,
5495
- }, 409);
5802
+ return refuse(c, 'conflict', `restored forward into '${route}', but the kept copy in '${body.script}' changed since it was read, so it ` +
5803
+ `stays kept: read it again and resolve it again`);
5496
5804
  }
5497
5805
  return c.json({ scopeId, script: body.script, action: 'restore-forward', liveScript, revisionBefore, revisionAfter });
5498
5806
  });
@@ -5550,7 +5858,7 @@ export function createControlPlaneApi(options) {
5550
5858
  };
5551
5859
  if (snapshot) {
5552
5860
  if (!scope) {
5553
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
5861
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5554
5862
  }
5555
5863
  const vertical = await verticalForScope(c, scope);
5556
5864
  if (vertical) {
@@ -5625,13 +5933,13 @@ export function createControlPlaneApi(options) {
5625
5933
  // uniform whether or not anything is deployed. Existing scopes keep serving.
5626
5934
  const registered = (await c.var.admin.listVerticals(c.get('actor'))).find((v) => v.slug === slug);
5627
5935
  if (registered?.installsBlocked) {
5628
- return c.json({ error: `new installs of vertical '${slug}' are blocked` }, 403);
5936
+ return refuse(c, 'forbidden', `new installs of vertical '${slug}' are blocked`);
5629
5937
  }
5630
5938
  // Static binding first (milestone-one shape), then the dispatch resolver for a
5631
5939
  // pushed vertical — the provisioning mirror of the router's verticalFor.
5632
5940
  const vertical = options.verticals?.[slug] ?? (await options.resolveVertical?.(slug, c.get('actor')));
5633
5941
  if (!vertical) {
5634
- return c.json({ error: `no deployment is bound for vertical '${slug}'` }, 501);
5942
+ return refuseStatus(c, 501, `no deployment is bound for vertical '${slug}'`);
5635
5943
  }
5636
5944
  const input = provisionInstanceBody.parse(await c.req.json());
5637
5945
  // #310: the platform is the authoritative source of the tenant's entitlements — it
@@ -5750,7 +6058,7 @@ export function createControlPlaneApi(options) {
5750
6058
  message: e.message,
5751
6059
  }, e);
5752
6060
  }
5753
- return c.json({ error: e.message }, failedWith(c, e));
6061
+ return relayRefusal(c, e);
5754
6062
  }
5755
6063
  throw e;
5756
6064
  }
@@ -5819,6 +6127,16 @@ export function createControlPlaneApi(options) {
5819
6127
  const v = await verticalOf(p.actor, slug);
5820
6128
  return !v || (v.ownerTenant !== pin && !v.listed);
5821
6129
  };
6130
+ /**
6131
+ * #2347: the one visibility rule for a rebind across verticals and for the review it answers.
6132
+ * A confined caller sees a crossing only when it can read both ends (`notReadable`: its own
6133
+ * tenant's vertical, or a listed one), so whoever may make a rebind may read what it applies, and
6134
+ * nobody reads the migrations of a vertical it could not see. A scope with no vertical (null)
6135
+ * hides nothing here: the rebind refuses it on its own terms.
6136
+ */
6137
+ const crossingHidden = async (p, source, target) => (await notReadable(p, target)) || (source !== null && (await notReadable(p, source)));
6138
+ /** What a hidden crossing answers, the same bytes whichever end is hidden. */
6139
+ const crossingNotFound = (c) => refuse(c, 'not_found', 'not found');
5822
6140
  // The vertical id a request actually addresses. For a BUILDER it is `<tenantSlug>/<name>`
5823
6141
  // (builder-plane.md §5): they send a bare `--slug`, the control plane forms the prefix
5824
6142
  // from their authenticated tenant — so two builders can each own a `helpdesk` with no
@@ -5979,7 +6297,7 @@ export function createControlPlaneApi(options) {
5979
6297
  input.slug = effectiveSlug(p, input.slug);
5980
6298
  const owner = await ownerOf(p.actor, input.slug);
5981
6299
  if (owner !== undefined && owner !== p.tenantId)
5982
- return c.json({ error: 'forbidden' }, 403);
6300
+ return refuse(c, 'forbidden', 'forbidden');
5983
6301
  input.ownerTenant = p.tenantId;
5984
6302
  }
5985
6303
  await c.var.admin.registerVertical(c.get('actor'), input);
@@ -5994,7 +6312,7 @@ export function createControlPlaneApi(options) {
5994
6312
  // A builder reading a vertical it does not own gets 404 — indistinguishable from
5995
6313
  // absent, the same fail-closed reflex K-3 uses for a cross-tenant scope.
5996
6314
  if (await notReadable(p, slug)) {
5997
- return c.json({ error: 'not found' }, 404);
6315
+ return refuse(c, 'not_found', 'not found');
5998
6316
  }
5999
6317
  const page = pageParams(c);
6000
6318
  const entries = await c.var.admin.listVersions(c.get('actor'), slug, page);
@@ -6006,12 +6324,12 @@ export function createControlPlaneApi(options) {
6006
6324
  // the same fail-closed cross-check `(tenantId, scopeId)` makes (K-3) — a mismatch is
6007
6325
  // a client bug, not a silent publish under the wrong vertical.
6008
6326
  if (input.verticalSlug !== c.req.param('slug')) {
6009
- return c.json({ error: 'verticalSlug does not match the path' }, 400);
6327
+ return refuse(c, 'validation_failed', 'verticalSlug does not match the path');
6010
6328
  }
6011
6329
  const p = c.get('principal');
6012
6330
  const slug = await resolveVerticalId(c, input.verticalSlug);
6013
6331
  if (await notOwned(p, slug)) {
6014
- return c.json({ error: 'forbidden' }, 403);
6332
+ return refuse(c, 'forbidden', 'forbidden');
6015
6333
  }
6016
6334
  input.verticalSlug = slug;
6017
6335
  // `manifestJson` is a string the schema cannot look inside, and every reader parses it
@@ -6028,19 +6346,19 @@ export function createControlPlaneApi(options) {
6028
6346
  raw = JSON.parse(input.manifestJson);
6029
6347
  }
6030
6348
  catch {
6031
- return c.json({ error: 'manifestJson is not valid JSON' }, 400);
6349
+ return refuse(c, 'validation_failed', 'manifestJson is not valid JSON');
6032
6350
  }
6033
- const refuse = (issue, under = []) => c.json({ error: `manifestJson is not a deploy manifest: ${[...under, ...issue.path].join('.') || '(root)'}: ${issue.message}` }, 400);
6351
+ const refuseManifest = (issue, under = []) => refuse(c, 'validation_failed', `manifestJson is not a deploy manifest: ${[...under, ...issue.path].join('.') || '(root)'}: ${issue.message}`);
6034
6352
  const checked = storedDeployManifest.safeParse(raw);
6035
6353
  if (!checked.success)
6036
- return refuse(checked.error.issues[0]);
6354
+ return refuseManifest(checked.error.issues[0]);
6037
6355
  // #1869: a registry handed to this route now is NEW, so it answers to the push-time rule on
6038
6356
  // entity-grant shapes (no kernel namespace as an entity type), as `deployManifest` does.
6039
6357
  // An absent registry stays accepted, as above.
6040
6358
  if (checked.data.registry) {
6041
6359
  const registry = pushedPermissionRegistry.safeParse(checked.data.registry);
6042
6360
  if (!registry.success)
6043
- return refuse(registry.error.issues[0], ['registry']);
6361
+ return refuseManifest(registry.error.issues[0], ['registry']);
6044
6362
  }
6045
6363
  }
6046
6364
  await c.var.admin.publishVersion(c.get('actor'), input);
@@ -6057,7 +6375,7 @@ export function createControlPlaneApi(options) {
6057
6375
  const p = c.get('principal');
6058
6376
  const slug = await resolveVerticalId(c, c.req.param('slug'));
6059
6377
  if (await notReadable(p, slug)) {
6060
- return c.json({ error: 'not found' }, 404);
6378
+ return refuse(c, 'not_found', 'not found');
6061
6379
  }
6062
6380
  const json = await c.var.admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
6063
6381
  const registry = json ? (storedDeployManifest.parse(JSON.parse(json)).registry ?? null) : null;
@@ -6075,16 +6393,32 @@ export function createControlPlaneApi(options) {
6075
6393
  const p = c.get('principal');
6076
6394
  const slug = await resolveVerticalId(c, c.req.param('slug'));
6077
6395
  if (await notOwned(p, slug)) {
6078
- return c.json({ error: 'not found' }, 404);
6396
+ return refuse(c, 'not_found', 'not found');
6079
6397
  }
6080
6398
  // `versionMigrations` refuses a version of another vertical, so `base` cannot reach one.
6081
6399
  const migrationsOf = (id) => c.var.admin.versionMigrations(c.get('actor'), slug, id);
6400
+ const versionOf = async (id) => {
6401
+ const v = await c.var.admin.getVersion(c.get('actor'), id, slug);
6402
+ if (!v)
6403
+ throw substratError('not_found', `unknown version ${id} for vertical '${slug}'`);
6404
+ return v;
6405
+ };
6082
6406
  const base = c.req.query('base');
6083
- const [incoming, baseline] = await Promise.all([
6084
- migrationsOf(c.req.param('id')),
6407
+ const incomingId = c.req.param('id');
6408
+ const [incoming, baseline, incomingVersion, baseVersion] = await Promise.all([
6409
+ migrationsOf(incomingId),
6085
6410
  base === undefined ? undefined : migrationsOf(base),
6411
+ versionOf(incomingId),
6412
+ base === undefined ? null : versionOf(base),
6086
6413
  ]);
6087
- return c.json({ migrations: incoming === null ? null : migrationsOnTop(incoming, baseline) });
6414
+ // #2333: the binding a promote's acknowledgement carries, computed from the very rows this
6415
+ // answer shows, in this answer — so a client cannot show one diff and bind another. The
6416
+ // promote recomputes it from its own stored rows and refuses when it no longer matches.
6417
+ const review = {
6418
+ base: base ?? null,
6419
+ digest: await promoteReviewDigest(baseVersion && { ...baseVersion, migrations: baseline ?? null }, { ...incomingVersion, migrations: incoming }),
6420
+ };
6421
+ return c.json({ migrations: incoming === null ? null : migrationsOnTop(incoming, baseline), review });
6088
6422
  });
6089
6423
  // The emitted entity model (#1214) one version ships: entities, field schemas, parent
6090
6424
  // edges, declared lifecycles — read out of the same retained manifest as the registry
@@ -6097,7 +6431,7 @@ export function createControlPlaneApi(options) {
6097
6431
  const p = c.get('principal');
6098
6432
  const slug = await resolveVerticalId(c, c.req.param('slug'));
6099
6433
  if (await notReadable(p, slug)) {
6100
- return c.json({ error: 'not found' }, 404);
6434
+ return refuse(c, 'not_found', 'not found');
6101
6435
  }
6102
6436
  const json = await c.var.admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
6103
6437
  const parsed = json ? storedDeployManifest.parse(JSON.parse(json)) : null;
@@ -6120,7 +6454,7 @@ export function createControlPlaneApi(options) {
6120
6454
  const p = c.get('principal');
6121
6455
  const slug = await resolveVerticalId(c, c.req.param('slug'));
6122
6456
  if (await notReadable(p, slug)) {
6123
- return c.json({ error: 'not found' }, 404);
6457
+ return refuse(c, 'not_found', 'not found');
6124
6458
  }
6125
6459
  const json = await c.var.admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
6126
6460
  const parsed = json ? storedDeployManifest.parse(JSON.parse(json)) : null;
@@ -6148,7 +6482,7 @@ export function createControlPlaneApi(options) {
6148
6482
  const p = c.get('principal');
6149
6483
  const slug = await resolveVerticalId(c, c.req.param('slug'));
6150
6484
  if (await notReadable(p, slug)) {
6151
- return c.json({ error: 'not found' }, 404);
6485
+ return refuse(c, 'not_found', 'not found');
6152
6486
  }
6153
6487
  const json = await c.var.admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
6154
6488
  const parsed = json ? storedDeployManifest.parse(JSON.parse(json)) : null;
@@ -6167,7 +6501,7 @@ export function createControlPlaneApi(options) {
6167
6501
  const p = c.get('principal');
6168
6502
  const slug = await resolveVerticalId(c, c.req.param('slug'));
6169
6503
  if (await notReadable(p, slug)) {
6170
- return c.json({ error: 'not found' }, 404);
6504
+ return refuse(c, 'not_found', 'not found');
6171
6505
  }
6172
6506
  const json = await c.var.admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
6173
6507
  const assets = json ? (storedDeployManifest.parse(JSON.parse(json)).assets ?? null) : null;
@@ -6192,7 +6526,7 @@ export function createControlPlaneApi(options) {
6192
6526
  const p = c.get('principal');
6193
6527
  const slug = await resolveVerticalId(c, c.req.param('slug'));
6194
6528
  if (await notOwned(p, slug)) {
6195
- return c.json({ error: 'not found' }, 404);
6529
+ return refuse(c, 'not_found', 'not found');
6196
6530
  }
6197
6531
  await c.var.admin.requestPublish(c.get('actor'), slug);
6198
6532
  return c.json({ slug, requested: true });
@@ -6248,7 +6582,7 @@ export function createControlPlaneApi(options) {
6248
6582
  // 404, not 403: a slug this tenant does not own reads as absent, the same
6249
6583
  // existence-hiding reflex the registry's other per-slug reads use (K-3).
6250
6584
  if (await notOwned(c.get('principal'), slug))
6251
- return c.json({ error: 'not found' }, 404);
6585
+ return refuse(c, 'not_found', 'not found');
6252
6586
  await c.var.admin.deleteVertical(c.get('actor'), slug);
6253
6587
  return c.json({ slug, deleted: true });
6254
6588
  });
@@ -6256,7 +6590,7 @@ export function createControlPlaneApi(options) {
6256
6590
  const p = c.get('principal');
6257
6591
  const slug = await resolveVerticalId(c, c.req.param('slug'));
6258
6592
  if (await notReadable(p, slug)) {
6259
- return c.json({ error: 'not found' }, 404);
6593
+ return refuse(c, 'not_found', 'not found');
6260
6594
  }
6261
6595
  const page = pageParams(c);
6262
6596
  const entries = await c.var.admin.listChannels(c.get('actor'), slug, page);
@@ -6270,16 +6604,74 @@ export function createControlPlaneApi(options) {
6270
6604
  const slug = await resolveVerticalId(c, c.req.param('slug'));
6271
6605
  const rawChannel = c.req.param('channel');
6272
6606
  if (rawChannel !== 'prod') {
6273
- return c.json({ error: `channel '${rawChannel}' is retired — only 'prod' has history` }, 400);
6607
+ return refuse(c, 'validation_failed', `channel '${rawChannel}' is retired — only 'prod' has history`);
6274
6608
  }
6275
6609
  const channel = channelName.parse(rawChannel);
6276
6610
  if (await notOwned(p, slug)) {
6277
- return c.json({ error: 'not found' }, 404);
6611
+ return refuse(c, 'not_found', 'not found');
6278
6612
  }
6279
6613
  const page = pageParams(c);
6280
6614
  const entries = await c.var.admin.listChannelHistory(c.get('actor'), slug, channel, page);
6281
6615
  return c.json(pageOf(entries, page.limit, (e) => e.id));
6282
6616
  });
6617
+ /**
6618
+ * Puts the store scripts a version's declared stores need in place, before its vertical is
6619
+ * uploaded (#2210). A declaration with no uploader configured is refused (501) rather than
6620
+ * served without its store. A failure is recorded as an ops failure at stage `store-upload`
6621
+ * (#559), then rethrown, so the push or serve answers it and the vertical upload never runs.
6622
+ * The row carries the vertical's owner, so it shows in that tenant's failure view; the serve
6623
+ * path reads the owner only once something has failed.
6624
+ */
6625
+ const ensureScopeStoresOf = async (actor, operation, slug, stores, owner) => {
6626
+ if (stores.length === 0)
6627
+ return;
6628
+ const holder = ulid();
6629
+ const drivers = [...new Set(stores.map((store) => store.driver))].sort();
6630
+ const held = [];
6631
+ try {
6632
+ if (!options.ensureScopeStores) {
6633
+ throw new ControlPlaneError(501, `${operation} refused: '${slug}' declares a scope store and this control plane uploads no store script`);
6634
+ }
6635
+ // A shared hold on each driver (#2300): a rollout holds it exclusively, and a rebuild that read
6636
+ // a script's release before a rollout moved it would upload the old release back over it.
6637
+ // Neither the refusal nor anything else here names another vertical: a push answers its own.
6638
+ for (const driver of drivers) {
6639
+ if (!(await host.admin.holdStoreDriver(actor, { driver, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS }))) {
6640
+ throw new ControlPlaneError(409, `${operation} refused: scope store driver '${driver}' is being rolled out; try again in a few minutes`);
6641
+ }
6642
+ held.push(driver);
6643
+ }
6644
+ const loadRelease = options.storeReleases
6645
+ ? storedReleaseLoader({ admin: host.admin, actor, bytes: options.storeReleases.bytes })
6646
+ : async () => undefined;
6647
+ // One driver at a time, each renewing every hold first: a push whose uploads outlast a hold
6648
+ // stops (409) rather than upload after a rollout could have taken the driver.
6649
+ for (const driver of drivers) {
6650
+ for (const each of held) {
6651
+ if (!(await host.admin.holdStoreDriver(actor, { driver: each, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS, renew: true }))) {
6652
+ throw new ControlPlaneError(409, `${operation} refused: the hold on scope store driver '${each}' lapsed during the upload; try again`);
6653
+ }
6654
+ }
6655
+ await options.ensureScopeStores({ vertical: slug, stores: stores.filter((store) => store.driver === driver), loadRelease });
6656
+ }
6657
+ }
6658
+ catch (e) {
6659
+ recordFailure({
6660
+ actor,
6661
+ operation: `${operation}.store-upload`,
6662
+ stage: 'store-upload',
6663
+ vertical: slug,
6664
+ tenantId: await owner().catch(() => null),
6665
+ status: e instanceof ControlPlaneError ? e.status : 502,
6666
+ message: e instanceof Error ? e.message : String(e),
6667
+ }, e);
6668
+ throw e;
6669
+ }
6670
+ finally {
6671
+ for (const driver of held)
6672
+ await host.admin.releaseStoreDriverHold(actor, { driver, holder }).catch(() => undefined);
6673
+ }
6674
+ };
6283
6675
  /**
6284
6676
  * Serve a version IN PLACE (#286): upload its bundle onto the vertical's ONE stable
6285
6677
  * serving script, where every scope's data DO lives — this is the step that makes a
@@ -6332,6 +6724,8 @@ export function createControlPlaneApi(options) {
6332
6724
  ].map((b) => b.type === 'd1'
6333
6725
  ? { type: 'd1', name: b.name, id: b.id }
6334
6726
  : { type: 'r2_bucket', name: b.name, bucket_name: b.bucketName });
6727
+ // #2210: the store scripts first, so the vertical is never served before what it reaches.
6728
+ await ensureScopeStoresOf(actor, 'serve', slug, manifest.scopeStores, async () => (await ownerOf(actor, slug)) ?? null);
6335
6729
  await options.deployVertical(ref, {
6336
6730
  entry: manifest.entry,
6337
6731
  compatibilityDate: manifest.compatibilityDate,
@@ -6339,8 +6733,17 @@ export function createControlPlaneApi(options) {
6339
6733
  modules,
6340
6734
  doClasses,
6341
6735
  // #2210: a declared scope store adds no binding to either upload (L1), held at both call
6342
- // sites with a kind registered by upload-scope-store-bindings.test.ts.
6343
- bindings: verticalUploadBindings({ vertical: slug, manifest, platformBindings: storeBindings }),
6736
+ // sites with a driver registered by upload-scope-store-bindings.test.ts.
6737
+ bindings: verticalUploadBindings({
6738
+ vertical: slug,
6739
+ manifest,
6740
+ // #2349: the digest of the served version's stored migrations, which its scopes hold
6741
+ // the SQL they apply to.
6742
+ platformBindings: [
6743
+ ...storeBindings,
6744
+ ...(await migrationsDigestBindings(await admin.versionMigrations(actor, slug, versionId))),
6745
+ ],
6746
+ }),
6344
6747
  // #1242: the version now being served, injected as `SUBSTRAT_VERSION_ID` so the
6345
6748
  // scope host stamps the signals `version` dimension. The serving script is one
6346
6749
  // stable script updated in place, so this refresh is what keeps the binding
@@ -6487,7 +6890,7 @@ export function createControlPlaneApi(options) {
6487
6890
  if (pin) {
6488
6891
  const v = await verticalOf(p.actor, slug);
6489
6892
  if (!v || v.ownerTenant !== pin)
6490
- return c.json({ error: 'forbidden' }, 403);
6893
+ return refuse(c, 'forbidden', 'forbidden');
6491
6894
  }
6492
6895
  const breaks = await promoteImpactOf(c.get('actor'), slug, channel, versionId);
6493
6896
  const { visible, otherTenants } = narrowToCaller(breaks, pin);
@@ -6500,10 +6903,8 @@ export function createControlPlaneApi(options) {
6500
6903
  // with the honest alternative — a non-prod environment is a scope with data, a preview.
6501
6904
  const rawChannel = c.req.param('channel');
6502
6905
  if (rawChannel !== 'prod') {
6503
- return c.json({
6504
- error: `channel '${rawChannel}' is retired — 'prod' is the only channel. To run a version ` +
6505
- `against non-prod data, create a preview: 'substrat preview create --tag <tag>'.`,
6506
- }, 400);
6906
+ return refuse(c, 'validation_failed', `channel '${rawChannel}' is retired — 'prod' is the only channel. To run a version ` +
6907
+ `against non-prod data, create a preview: 'substrat preview create --tag <tag>'.`);
6507
6908
  }
6508
6909
  const channel = channelName.parse(rawChannel);
6509
6910
  const promotePin = confinedTenant(p);
@@ -6511,7 +6912,7 @@ export function createControlPlaneApi(options) {
6511
6912
  // A confined principal promotes only verticals its tenant owns.
6512
6913
  const v = await verticalOf(p.actor, slug);
6513
6914
  if (!v || v.ownerTenant !== promotePin)
6514
- return c.json({ error: 'forbidden' }, 403);
6915
+ return refuse(c, 'forbidden', 'forbidden');
6515
6916
  // …and a BUILDER promotes prod only while the vertical is PRIVATE (not listed).
6516
6917
  // A private vertical's blast radius is the owning tenant itself, and dev/staging
6517
6918
  // already run the same bundle in the same sandbox, so a staff prod gate there
@@ -6525,7 +6926,7 @@ export function createControlPlaneApi(options) {
6525
6926
  // a tenant may do inside its own. Taking the gate away from them here would be a
6526
6927
  // capability change hidden in a security fix.
6527
6928
  if (p.kind === 'builder' && channel === 'prod' && v.listed) {
6528
- return c.json({ error: 'promotion to prod is staff-only for a listed vertical' }, 403);
6929
+ return refuse(c, 'forbidden', 'promotion to prod is staff-only for a listed vertical');
6529
6930
  }
6530
6931
  }
6531
6932
  const { versionId, acknowledge } = promoteVersionBody.parse(await c.req.json());
@@ -6563,7 +6964,7 @@ export function createControlPlaneApi(options) {
6563
6964
  exportBreaks: null,
6564
6965
  exportBreaksUnavailable: `which apps it breaks could not be read: ${e instanceof Error ? e.message : String(e)}`,
6565
6966
  }));
6566
- return c.json({ error: listing.exportBreaks ? exportBreakRefusal(counted) : err.message, ...listing }, 409);
6967
+ return refuse(c, 'conflict', listing.exportBreaks ? exportBreakRefusal(counted) : err.message, listing);
6567
6968
  }
6568
6969
  // The in-place serve (#286), prod only, AFTER every promote gate has passed —
6569
6970
  // uploading first would deploy to live scopes before the acknowledgement check.
@@ -6589,10 +6990,7 @@ export function createControlPlaneApi(options) {
6589
6990
  catch (e) {
6590
6991
  const detail = e instanceof Error ? e.message : String(e);
6591
6992
  console.error('serve.inplace.failed', { slug, versionId, detail });
6592
- return c.json({
6593
- error: 'promoted, but the in-place serve failed — scopes still run the previous code; promote again to retry',
6594
- detail,
6595
- }, 502);
6993
+ return refuseStatus(c, 502, 'promoted, but the in-place serve failed — scopes still run the previous code; promote again to retry', { detail });
6596
6994
  }
6597
6995
  }
6598
6996
  // #1756: served, and every install that could move has moved and been backfilled, but one
@@ -6601,9 +6999,8 @@ export function createControlPlaneApi(options) {
6601
6999
  // version. It is left where it is. Promoting again acknowledged moves it.
6602
7000
  if (adopted.refused.length > 0 || adopted.refusal) {
6603
7001
  const sentence = adopted.refused.length > 0 ? bindExportBreakRefusal(adopted.refused) : adopted.refusal;
6604
- return c.json({
6605
- error: `promoted and served, but an app still on its own version was not moved onto the serving script: ` +
6606
- `${sentence}. Promote again with the export-break acknowledgement to move it`,
7002
+ return refuse(c, 'conflict', `promoted and served, but an app still on its own version was not moved onto the serving script: ` +
7003
+ `${sentence}. Promote again with the export-break acknowledgement to move it`, {
6607
7004
  ...(adopted.refused.length > 0 ? { exportBreaks: { affected: adopted.refused } } : {}),
6608
7005
  // Narrowed as the success answer's is: a builder never reads another tenant's id here.
6609
7006
  ...(backfill.minted.length || backfill.error
@@ -6618,7 +7015,7 @@ export function createControlPlaneApi(options) {
6618
7015
  };
6619
7016
  })()
6620
7017
  : {}),
6621
- }, 409);
7018
+ });
6622
7019
  }
6623
7020
  const promoted = (await c.var.admin.listChannels(c.get('actor'), slug)).find((ch) => ch.channel === channel);
6624
7021
  // The store backfill rides the response only when it has something to say (#825), so
@@ -6656,16 +7053,22 @@ export function createControlPlaneApi(options) {
6656
7053
  // tenant); for everything else admission still gates serving.
6657
7054
  app.post('/verticals/:slug/deploy', async (c) => {
6658
7055
  if (!options.deployVertical) {
6659
- return c.json({ error: 'deploy is not configured on this control plane' }, 501);
7056
+ return refuseStatus(c, 501, 'deploy is not configured on this control plane');
6660
7057
  }
6661
7058
  const p = c.get('principal');
6662
7059
  const form = await c.req.formData();
6663
7060
  const raw = form.get('manifest');
6664
7061
  if (typeof raw !== 'string')
6665
- return c.json({ error: 'missing manifest part' }, 400);
7062
+ return refuse(c, 'validation_failed', 'missing manifest part');
6666
7063
  const manifest = deployManifest.parse(JSON.parse(raw));
6667
7064
  // §4 sandbox contract, before anything reaches the namespace.
6668
7065
  assertSandboxContract(manifest);
7066
+ // #2339: a push whose migration digest is not the one its migrations give is refused here,
7067
+ // before the upload, by the derivation `publishVersion` stores (and refuses with) again.
7068
+ await publishedMigrationDigest({
7069
+ id: 'push', migrationDigest: manifest.digests.migration, manifestJson: JSON.stringify(manifest),
7070
+ migrations: manifest.migrations ?? null,
7071
+ });
6669
7072
  // The workspace this push is FOR — the project's pin (package.json `substrat.tenant`),
6670
7073
  // sent by the CLI alongside the bundle. The pin is intent, and intent is honored or
6671
7074
  // refused, never silently reinterpreted: a BUILDER's workspace is already fixed by
@@ -6699,20 +7102,20 @@ export function createControlPlaneApi(options) {
6699
7102
  let ownerTenant;
6700
7103
  if (p.kind === 'builder') {
6701
7104
  if (pin && pin !== p.tenantSlug && pin !== p.tenantId) {
6702
- return c.json({ error: `push is pinned to workspace '${pin}' but this session acts for '${p.tenantSlug}'` }, 403);
7105
+ return refuse(c, 'forbidden', `push is pinned to workspace '${pin}' but this session acts for '${p.tenantSlug}'`);
6703
7106
  }
6704
7107
  slug = effectiveSlug(p, bare);
6705
7108
  // A builder pushes to a slug it owns, or claims an unregistered one (§3).
6706
7109
  const existingOwner = await ownerOf(c.get('actor'), slug);
6707
7110
  if (existingOwner !== undefined && existingOwner !== p.tenantId) {
6708
- return c.json({ error: 'forbidden' }, 403);
7111
+ return refuse(c, 'forbidden', 'forbidden');
6709
7112
  }
6710
7113
  ownerTenant = p.tenantId;
6711
7114
  }
6712
7115
  else if (pin) {
6713
7116
  const workspace = (await c.var.admin.listTenants(c.get('actor'))).find((t) => t.slug === pin || t.id === pin);
6714
7117
  if (!workspace)
6715
- return c.json({ error: `unknown workspace '${pin}'` }, 404);
7118
+ return refuse(c, 'not_found', `unknown workspace '${pin}'`);
6716
7119
  // Back-compat: a BARE slug already registered as the pinned tenant's (a staff
6717
7120
  // hand-registration predating prefixed claims) stays addressable as itself.
6718
7121
  // Otherwise the claim lands under the tenant prefix, exactly like a builder push —
@@ -6721,7 +7124,7 @@ export function createControlPlaneApi(options) {
6721
7124
  slug = bareOwner === workspace.id ? bare : `${workspace.slug}/${bare}`;
6722
7125
  const existingOwner = slug === bare ? bareOwner : await ownerOf(c.get('actor'), slug);
6723
7126
  if (existingOwner !== undefined && existingOwner !== workspace.id) {
6724
- return c.json({ error: `vertical '${slug}' is not owned by workspace '${pin}'` }, 403);
7127
+ return refuse(c, 'forbidden', `vertical '${slug}' is not owned by workspace '${pin}'`);
6725
7128
  }
6726
7129
  ownerTenant = workspace.id;
6727
7130
  }
@@ -6736,7 +7139,7 @@ export function createControlPlaneApi(options) {
6736
7139
  // registry id that predates the rule, so it can never put a script under a platform name.
6737
7140
  const collision = platformScriptCollision(slug);
6738
7141
  if (collision)
6739
- return c.json({ error: platformScriptRefusal(slug, collision) }, 422);
7142
+ return refuseStatus(c, 422, platformScriptRefusal(slug, collision));
6740
7143
  // #388: refuse the SILENT lineage fork. A first push of a registry id that does not
6741
7144
  // exist yet, whose product name (the slug's tail) matches an existing lineage this
6742
7145
  // push could CONFUSE ITSELF WITH, is almost always a mis-identified project — a
@@ -6771,13 +7174,11 @@ export function createControlPlaneApi(options) {
6771
7174
  ? 'owned by this same workspace'
6772
7175
  : `owned by tenant ${v.ownerTenant}`;
6773
7176
  const named = siblings.map((v) => `'${v.slug}' (${owner(v)}${v.listed ? ', listed' : ''})`).join(', ');
6774
- return c.json({
6775
- error: `this push would CREATE a new vertical '${slug}' — a separate lineage beside ${named}. ` +
6776
- `Installs of the existing lineage never update from pushes to '${slug}'. If you meant to ` +
6777
- `update it, fix the project's identity first: package.json \`substrat.slug\` and ` +
6778
- `\`substrat.tenant\` (or --tenant) decide where a push lands. To deliberately run a ` +
6779
- `separate same-named lineage, re-run with --allow-fork.`,
6780
- }, 409);
7177
+ return refuse(c, 'conflict', `this push would CREATE a new vertical '${slug}' — a separate lineage beside ${named}. ` +
7178
+ `Installs of the existing lineage never update from pushes to '${slug}'. If you meant to ` +
7179
+ `update it, fix the project's identity first: package.json \`substrat.slug\` and ` +
7180
+ `\`substrat.tenant\` (or --tenant) decide where a push lands. To deliberately run a ` +
7181
+ `separate same-named lineage, re-run with --allow-fork.`);
6781
7182
  }
6782
7183
  }
6783
7184
  // Two kinds of part in one body (#340): worker MODULES (unprefixed) and static ASSETS
@@ -6802,7 +7203,7 @@ export function createControlPlaneApi(options) {
6802
7203
  });
6803
7204
  }
6804
7205
  if (!modules.some((m) => m.name === manifest.entry)) {
6805
- return c.json({ error: `entry module '${manifest.entry}' is not among the uploaded files` }, 400);
7206
+ return refuse(c, 'validation_failed', `entry module '${manifest.entry}' is not among the uploaded files`);
6806
7207
  }
6807
7208
  // The static-asset half of the §4 sandbox contract (self-serve-deploy.md §4.1). The
6808
7209
  // bytes are inert — no code, no authority — so they are ACCEPTED; the content-address
@@ -6815,19 +7216,17 @@ export function createControlPlaneApi(options) {
6815
7216
  for (const file of manifest.assets?.files ?? []) {
6816
7217
  const part = assetParts.get(file.path);
6817
7218
  if (!part) {
6818
- return c.json({ error: `asset '${file.path}' is named in the manifest but was not uploaded` }, 400);
7219
+ return refuse(c, 'validation_failed', `asset '${file.path}' is named in the manifest but was not uploaded`);
6819
7220
  }
6820
7221
  const content = new Uint8Array(await part.arrayBuffer());
6821
7222
  if (content.byteLength !== file.size) {
6822
- return c.json({ error: `asset '${file.path}' declares ${file.size} bytes but ${content.byteLength} arrived` }, 400);
7223
+ return refuse(c, 'validation_failed', `asset '${file.path}' declares ${file.size} bytes but ${content.byteLength} arrived`);
6823
7224
  }
6824
7225
  const actual = await assetHash(content, file.path);
6825
7226
  if (actual !== file.hash) {
6826
- return c.json({
6827
- error: `asset '${file.path}' does not match its declared content hash ` +
6828
- `(declared ${file.hash}, computed ${actual}) — the hash is the runtime's shared dedup key, ` +
6829
- `so a mismatch is refused rather than stored`,
6830
- }, 400);
7227
+ return refuse(c, 'validation_failed', `asset '${file.path}' does not match its declared content hash ` +
7228
+ `(declared ${file.hash}, computed ${actual}) — the hash is the runtime's shared dedup key, ` +
7229
+ `so a mismatch is refused rather than stored`);
6831
7230
  }
6832
7231
  assets.push({
6833
7232
  path: file.path,
@@ -6842,9 +7241,7 @@ export function createControlPlaneApi(options) {
6842
7241
  // An unlisted asset part would be uploaded by nothing and served by nothing; saying so
6843
7242
  // beats silently dropping it, because the builder's page would 404 with no explanation.
6844
7243
  const extra = [...assetParts.keys()].slice(0, 5).join(', ');
6845
- return c.json({
6846
- error: `${assetParts.size} uploaded asset part(s) are absent from the manifest (${extra}${assetParts.size > 5 ? ', …' : ''})`,
6847
- }, 400);
7244
+ return refuse(c, 'validation_failed', `${assetParts.size} uploaded asset part(s) are absent from the manifest (${extra}${assetParts.size > 5 ? ', …' : ''})`);
6848
7245
  }
6849
7246
  // #1902: whether this version's uploads carry the platform's scope sweeper — decided here,
6850
7247
  // ONCE, before anything is uploaded, and recorded with the version below so every later
@@ -6852,11 +7249,14 @@ export function createControlPlaneApi(options) {
6852
7249
  // while it is still a push; it can never become a promote or a backout that refuses.
6853
7250
  const sweeper = platformSweeperDecision(manifest, { entry: manifest.entry, modules });
6854
7251
  if ('refuse' in sweeper)
6855
- return c.json({ error: sweeper.refuse }, 422);
7252
+ return refuseStatus(c, 422, sweeper.refuse);
6856
7253
  // Mint the version id first: the deploymentRef (the dispatch script name) is keyed
6857
7254
  // on it, so it is CF-valid and unique per version.
6858
7255
  const id = ulid();
6859
7256
  const deploymentRef = deploymentRefFor(slug, id);
7257
+ // #2210: the store scripts first, outside the vertical upload's try, so a store failure
7258
+ // answers as itself and the vertical is not uploaded at all.
7259
+ await ensureScopeStoresOf(c.get('actor'), 'deploy', slug, manifest.scopeStores, async () => ownerTenant ?? null);
6860
7260
  try {
6861
7261
  await options.deployVertical(deploymentRef, {
6862
7262
  entry: manifest.entry,
@@ -6864,7 +7264,12 @@ export function createControlPlaneApi(options) {
6864
7264
  compatibilityFlags: manifest.compatibilityFlags,
6865
7265
  modules,
6866
7266
  doClasses: manifest.doClasses,
6867
- bindings: verticalUploadBindings({ vertical: slug, manifest, platformBindings: [] }),
7267
+ bindings: verticalUploadBindings({
7268
+ vertical: slug,
7269
+ manifest,
7270
+ // #2349: the migrations this push declares, which are the ones the version stores.
7271
+ platformBindings: await migrationsDigestBindings(manifest.migrations ?? null),
7272
+ }),
6868
7273
  // #1242: the freshly minted version id — this archive script IS that version,
6869
7274
  // and a preview routed at it stamps the signals `version` dimension honestly.
6870
7275
  versionId: id,
@@ -6905,8 +7310,8 @@ export function createControlPlaneApi(options) {
6905
7310
  }, e);
6906
7311
  c.set('failureRecorded', true);
6907
7312
  return rejected
6908
- ? c.json({ error: 'deploy rejected', detail }, 422)
6909
- : c.json({ error: 'deploy upload failed', detail }, 502);
7313
+ ? refuseStatus(c, 422, 'deploy rejected', { detail })
7314
+ : refuseStatus(c, 502, 'deploy upload failed', { detail });
6910
7315
  }
6911
7316
  // Register-then-publish, both idempotent-ish below the seam: a first push of a
6912
7317
  // slug registers it; publishVersion lands the version pending with deploymentRef
@@ -6993,7 +7398,7 @@ export function createControlPlaneApi(options) {
6993
7398
  // because "no namespaces in that script" and "I cannot look" are different answers.
6994
7399
  app.get('/platform/do-namespaces', async (c) => {
6995
7400
  if (!options.doNamespaces) {
6996
- return c.json({ error: 'durable-object namespace lookup is not configured on this control plane' }, 501);
7401
+ return refuseStatus(c, 501, 'durable-object namespace lookup is not configured on this control plane');
6997
7402
  }
6998
7403
  const { script } = z.object({ script: z.string().min(1).max(200) }).parse({ script: c.req.query('script') });
6999
7404
  return c.json(namespacesForScript(await options.doNamespaces.list(), script));
@@ -7013,7 +7418,7 @@ export function createControlPlaneApi(options) {
7013
7418
  // on the route where a mistake is least visible.
7014
7419
  app.get('/observability/metrics', async (c) => {
7015
7420
  if (!options.observability) {
7016
- return c.json({ error: 'observability is not configured on this control plane' }, 501);
7421
+ return refuseStatus(c, 501, 'observability is not configured on this control plane');
7017
7422
  }
7018
7423
  const { hours } = z
7019
7424
  .object({ hours: z.coerce.number().int().min(1).max(72).default(24) })
@@ -7030,7 +7435,7 @@ export function createControlPlaneApi(options) {
7030
7435
  // 501 when the reader cannot bucket — an empty series would render as "quiet".
7031
7436
  app.get('/observability/metrics-series', async (c) => {
7032
7437
  if (!options.observability?.serviceMetricsSeries) {
7033
- return c.json({ error: 'bucketed observability is not configured on this control plane' }, 501);
7438
+ return refuseStatus(c, 501, 'bucketed observability is not configured on this control plane');
7034
7439
  }
7035
7440
  const { hours } = z
7036
7441
  .object({ hours: z.coerce.number().int().min(1).max(72).default(24) })
@@ -7054,7 +7459,7 @@ export function createControlPlaneApi(options) {
7054
7459
  });
7055
7460
  app.get('/observability/logs', async (c) => {
7056
7461
  if (!options.observability) {
7057
- return c.json({ error: 'observability is not configured on this control plane' }, 501);
7462
+ return refuseStatus(c, 501, 'observability is not configured on this control plane');
7058
7463
  }
7059
7464
  // `service` repeats: one deployed unit per param, so a caller can ask for a
7060
7465
  // vertical's whole set (the dashboard's "all versions") in one query and get one
@@ -7100,7 +7505,7 @@ export function createControlPlaneApi(options) {
7100
7505
  // a forgotten parameter is the exact leak being closed here.
7101
7506
  app.get('/observability/tenant-metrics', async (c) => {
7102
7507
  if (!options.observability?.tenantMetrics) {
7103
- return c.json({ error: 'tenant-grain observability is not configured on this control plane' }, 501);
7508
+ return refuseStatus(c, 501, 'tenant-grain observability is not configured on this control plane');
7104
7509
  }
7105
7510
  const p = c.get('principal');
7106
7511
  const tenantId = confinedTenant(p) ?? c.req.query('tenantId');
@@ -7145,7 +7550,7 @@ export function createControlPlaneApi(options) {
7145
7550
  // buckets, and at least one is required because an unnarrowed ask is not on offer.
7146
7551
  app.get('/observability/tenant-metrics-series', async (c) => {
7147
7552
  if (!options.observability?.tenantMetricsSeries) {
7148
- return c.json({ error: 'bucketed tenant-grain observability is not configured on this control plane' }, 501);
7553
+ return refuseStatus(c, 501, 'bucketed tenant-grain observability is not configured on this control plane');
7149
7554
  }
7150
7555
  const p = c.get('principal');
7151
7556
  const tenantId = confinedTenant(p) ?? c.req.query('tenantId');
@@ -7176,7 +7581,7 @@ export function createControlPlaneApi(options) {
7176
7581
  });
7177
7582
  app.get('/observability/tenant-logs', async (c) => {
7178
7583
  if (!options.observability?.tenantLogs) {
7179
- return c.json({ error: 'tenant-grain observability is not configured on this control plane' }, 501);
7584
+ return refuseStatus(c, 501, 'tenant-grain observability is not configured on this control plane');
7180
7585
  }
7181
7586
  const p = c.get('principal');
7182
7587
  const tenantId = confinedTenant(p) ?? c.req.query('tenantId');
@@ -7234,7 +7639,7 @@ export function createControlPlaneApi(options) {
7234
7639
  // #1331: one app's sampled field reports, joined to the router's own request lines.
7235
7640
  app.get('/observability/tenant-field-coverage', async (c) => {
7236
7641
  if (!options.observability?.fieldCoverage) {
7237
- return c.json({ error: 'field coverage is not configured on this control plane' }, 501);
7642
+ return refuseStatus(c, 501, 'field coverage is not configured on this control plane');
7238
7643
  }
7239
7644
  const tenantId = confinedTenant(c.get('principal')) ?? c.req.query('tenantId');
7240
7645
  if (!tenantId)
@@ -7257,11 +7662,11 @@ export function createControlPlaneApi(options) {
7257
7662
  const scopes = await host.admin.listScopes(c.get('actor'), { tenantId: input.tenantId });
7258
7663
  const installed = scopes.find((s) => s.id === input.scopeId);
7259
7664
  if (!installed?.vertical)
7260
- return c.json({ error: 'app not found' }, 404);
7665
+ return refuse(c, 'not_found', 'app not found');
7261
7666
  const services = scriptFamiliesOfScopes(scopes, { scopeId: input.scopeId });
7262
7667
  const json = await c.var.admin.versionManifest(c.get('actor'), installed.vertical, input.versionId);
7263
7668
  if (!json)
7264
- return c.json({ error: 'version not found' }, 404);
7669
+ return refuse(c, 'not_found', 'version not found');
7265
7670
  const outputSurface = storedDeployManifest.parse(JSON.parse(json)).outputSurface ?? [];
7266
7671
  const declared = Object.fromEntries(outputSurface.map((op) => [op.operationId, op.fields]));
7267
7672
  return c.json(await options.observability.fieldCoverage({
@@ -7297,7 +7702,7 @@ export function createControlPlaneApi(options) {
7297
7702
  const appServices = async (actor, tenantId, scopeId, vertical) => scriptFamiliesOfScopes(await host.admin.listScopes(actor, { tenantId: tenantIdSchema.parse(tenantId) }), { scopeId, vertical });
7298
7703
  app.get('/observability/tenant-request-volume', async (c) => {
7299
7704
  if (!options.observability?.tenantRequestVolume) {
7300
- return c.json({ error: 'request aggregation is not configured on this control plane' }, 501);
7705
+ return refuseStatus(c, 501, 'request aggregation is not configured on this control plane');
7301
7706
  }
7302
7707
  const scope = parseTenantRequestQuery(requestTenantOf(c), requestQueryOf(c));
7303
7708
  // A bucket COUNT: zooming in keeps it and narrows the window, so the bucket shrinks.
@@ -7308,7 +7713,7 @@ export function createControlPlaneApi(options) {
7308
7713
  });
7309
7714
  app.get('/observability/tenant-request-facets', async (c) => {
7310
7715
  if (!options.observability?.tenantRequestFacets) {
7311
- return c.json({ error: 'request aggregation is not configured on this control plane' }, 501);
7716
+ return refuseStatus(c, 501, 'request aggregation is not configured on this control plane');
7312
7717
  }
7313
7718
  const scope = parseTenantRequestQuery(requestTenantOf(c), requestQueryOf(c));
7314
7719
  // `facet` repeats; absent means every facet. Each one is a backend query, so a panel
@@ -7321,7 +7726,7 @@ export function createControlPlaneApi(options) {
7321
7726
  // #1747: the tenant's `ctx.log` lines grouped by the template they were written from.
7322
7727
  app.get('/observability/tenant-log-patterns', async (c) => {
7323
7728
  if (!options.observability?.tenantLogPatterns) {
7324
- return c.json({ error: 'log patterns are not configured on this control plane' }, 501);
7729
+ return refuseStatus(c, 501, 'log patterns are not configured on this control plane');
7325
7730
  }
7326
7731
  const scope = parseTenantWindowQuery(requestTenantOf(c), requestQueryOf(c));
7327
7732
  const narrow = z
@@ -7349,7 +7754,7 @@ export function createControlPlaneApi(options) {
7349
7754
  });
7350
7755
  app.get('/observability/tenant-requests', async (c) => {
7351
7756
  if (!options.observability?.tenantRequests) {
7352
- return c.json({ error: 'request aggregation is not configured on this control plane' }, 501);
7757
+ return refuseStatus(c, 501, 'request aggregation is not configured on this control plane');
7353
7758
  }
7354
7759
  const scope = parseTenantRequestQuery(requestTenantOf(c), requestQueryOf(c));
7355
7760
  const limit = z.coerce.number().int().min(1).max(200).default(50).parse(c.req.query('limit'));
@@ -7384,7 +7789,7 @@ export function createControlPlaneApi(options) {
7384
7789
  */
7385
7790
  app.get('/verticals/:slug/egress', async (c) => {
7386
7791
  if (!options.observability?.observedEgress) {
7387
- return c.json({ error: 'observed egress is not available on this control plane' }, 501);
7792
+ return refuseStatus(c, 501, 'observed egress is not available on this control plane');
7388
7793
  }
7389
7794
  const slug = await resolveVerticalId(c, c.req.param('slug'));
7390
7795
  const { hours, limit } = z
@@ -7455,12 +7860,12 @@ export function createControlPlaneApi(options) {
7455
7860
  // can NOT do (prod promote, admit, other tenants' slugs) holds for it identically.
7456
7861
  app.post('/push-tokens', async (c) => {
7457
7862
  if (!options.pushTokenSecret) {
7458
- return c.json({ error: 'push tokens are not configured on this control plane' }, 501);
7863
+ return refuseStatus(c, 501, 'push tokens are not configured on this control plane');
7459
7864
  }
7460
7865
  const { tenantId } = z.object({ tenantId: tenantIdSchema }).parse(await c.req.json());
7461
7866
  const tenant = await c.var.admin.getTenant(c.get('actor'), tenantId);
7462
7867
  if (!tenant)
7463
- return c.json({ error: `unknown tenant: ${tenantId}` }, 404);
7868
+ return refuse(c, 'not_found', `unknown tenant: ${tenantId}`);
7464
7869
  const token = await mintPushToken(options.pushTokenSecret, {
7465
7870
  actor: await pushActorFor(tenantId),
7466
7871
  tenantId,
@@ -7477,7 +7882,7 @@ export function createControlPlaneApi(options) {
7477
7882
  // ONLY fleet-wide thing it does. Everything else it does is spent per tenant.
7478
7883
  app.post('/tenant-tokens', async (c) => {
7479
7884
  if (!options.tenantTokenSecret) {
7480
- return c.json({ error: 'tenant tokens are not configured on this control plane' }, 501);
7885
+ return refuseStatus(c, 501, 'tenant tokens are not configured on this control plane');
7481
7886
  }
7482
7887
  const { tenantId, principal, impersonation } = z
7483
7888
  .object({
@@ -7604,7 +8009,7 @@ export function createControlPlaneApi(options) {
7604
8009
  // The body names the tenant the binding lands under; a confined principal may
7605
8010
  // only name its own (the adapter then verifies the scope belongs to it, K-3).
7606
8011
  if (input.tenantId !== bindPin)
7607
- return c.json({ error: 'forbidden' }, 403);
8012
+ return refuse(c, 'forbidden', 'forbidden');
7608
8013
  }
7609
8014
  if (p.kind === 'builder') {
7610
8015
  // The region column is an EU-residency claim (K-30) — never builder-suppliable.
@@ -7613,7 +8018,7 @@ export function createControlPlaneApi(options) {
7613
8018
  // the binding sits, so widening this to #977's credential would be a behaviour
7614
8019
  // change wearing a security fix's clothes.
7615
8020
  if (input.region !== null) {
7616
- return c.json({ error: 'region is derived from the scope, not chosen on a binding' }, 403);
8021
+ return refuse(c, 'forbidden', 'region is derived from the scope, not chosen on a binding');
7617
8022
  }
7618
8023
  }
7619
8024
  // Registrable-suffix guard (#305, D-35): a custom domain must be a real registrable
@@ -7622,7 +8027,7 @@ export function createControlPlaneApi(options) {
7622
8027
  if (custom(input.hostname)) {
7623
8028
  const bad = validateBindableHostname(input.hostname);
7624
8029
  if (bad)
7625
- return c.json({ error: bad }, 422);
8030
+ return refuseStatus(c, 422, bad);
7626
8031
  }
7627
8032
  await c.var.admin.bindHostname(c.get('actor'), input);
7628
8033
  const bound = (await c.var.admin.listHostnames(c.get('actor'), { scopeId: input.scopeId })).find((h) => h.hostname === input.hostname);
@@ -7639,11 +8044,11 @@ export function createControlPlaneApi(options) {
7639
8044
  app.post('/hostnames/:hostname/verify', async (c) => {
7640
8045
  const name = c.req.param('hostname');
7641
8046
  if (confinedTenant(c.get('principal')) !== null && !(await tenantHostname(c, name))) {
7642
- return c.json({ error: `unknown hostname: ${name.toLowerCase()}` }, 404);
8047
+ return refuse(c, 'not_found', `unknown hostname: ${name.toLowerCase()}`);
7643
8048
  }
7644
8049
  const row = await hostnameRow(c, name);
7645
8050
  if (!row)
7646
- return c.json({ error: `unknown hostname: ${name.toLowerCase()}` }, 404);
8051
+ return refuse(c, 'not_found', `unknown hostname: ${name.toLowerCase()}`);
7647
8052
  await runIssuance(c.var.admin, c.get('actor'), row);
7648
8053
  return c.json(await hostnameRow(c, name));
7649
8054
  });
@@ -7653,7 +8058,7 @@ export function createControlPlaneApi(options) {
7653
8058
  // `setHostnameStatus` normalizes and 404s an unknown one below the seam.
7654
8059
  const name = c.req.param('hostname');
7655
8060
  if (confinedTenant(c.get('principal')) !== null && !(await tenantHostname(c, name))) {
7656
- return c.json({ error: `unknown hostname: ${name.toLowerCase()}` }, 404);
8061
+ return refuse(c, 'not_found', `unknown hostname: ${name.toLowerCase()}`);
7657
8062
  }
7658
8063
  await c.var.admin.setHostnameStatus(c.get('actor'), name, status, note);
7659
8064
  const row = (await c.var.admin.listHostnames(c.get('actor'), {})).find((h) => h.hostname === name.toLowerCase());
@@ -7667,7 +8072,7 @@ export function createControlPlaneApi(options) {
7667
8072
  app.delete('/hostnames/:hostname', async (c) => {
7668
8073
  const name = c.req.param('hostname');
7669
8074
  if (confinedTenant(c.get('principal')) !== null && !(await tenantHostname(c, name))) {
7670
- return c.json({ error: `unknown hostname: ${name.toLowerCase()}` }, 404);
8075
+ return refuse(c, 'not_found', `unknown hostname: ${name.toLowerCase()}`);
7671
8076
  }
7672
8077
  // Release the Cloudflare custom hostname before dropping the row, or the CF object
7673
8078
  // leaks (billable, and it would block a future rebind of the same name). Best-effort:
@@ -7717,27 +8122,35 @@ export function createControlPlaneApi(options) {
7717
8122
  * reaps a HALF-BUILT leftover before re-forking (see `orchestratedPreview`). */
7718
8123
  /** Answers whether some of the preview's storage was stranded (and recorded as such). */
7719
8124
  const reapPreview = async (c, preview) => {
7720
- // A fork's own sign-in clients go FIRST (#1704): a failure here leaves the preview in
8125
+ const actor = c.get('actor');
8126
+ const dispatch = options.resolveVerticalRef;
8127
+ // #2285: every refusal that can be decided up front comes before anything is destroyed, so a
8128
+ // refused reap (a copy move in flight, a copy nothing can reach, stores that cannot be named)
8129
+ // leaves the preview routed, signing in, and whole.
8130
+ if (dispatch)
8131
+ await claimScopeScriptReap({ admin: c.var.admin, actor }, preview.tenantId, preview.id);
8132
+ else
8133
+ await assertNoUnreachableScopeCopies(c.var.admin, actor, preview.tenantId, preview.id);
8134
+ const wipeStores = await platformStoresWipe(c, preview);
8135
+ // A preview is still routed until its row goes: unroute before the wipe.
8136
+ await unrouteScope(c.var.admin, actor, preview);
8137
+ // A fork's own sign-in clients go once nothing routes to it, and before the row (#1704): an
8138
+ // unroute that fails leaves the preview signing in, and a failure here leaves the preview in
7721
8139
  // place, so the retry a PR-close job makes still finds it — after the row is gone, nothing
7722
8140
  // would name those clients again. A clean room never had one.
7723
8141
  await retireClientsOfReapedScope(previewAuthDeps(c), preview);
7724
- await wipePlatformStores(c, preview);
8142
+ await wipeStores();
7725
8143
  let storageStranded = false;
7726
- if (options.resolveVerticalRef) {
7727
- ({ storageStranded } = await reapScopeScriptCopies({
7728
- admin: c.var.admin,
7729
- actor: c.get('actor'),
7730
- resolveRef: options.resolveVerticalRef,
7731
- }, preview.tenantId, preview.id));
8144
+ if (dispatch) {
8145
+ ({ storageStranded } = await drainScopeScriptCopies({ admin: c.var.admin, actor, resolveRef: dispatch }, preview.tenantId, preview.id));
7732
8146
  }
7733
8147
  else {
7734
- await assertNoUnreachableScopeCopies(c.var.admin, c.get('actor'), preview.tenantId, preview.id);
7735
8148
  const vertical = await verticalForScope(c, preview);
7736
8149
  if (vertical)
7737
8150
  await vertical.deleteScope({ tenantId: preview.tenantId, scopeId: preview.id });
7738
8151
  }
7739
8152
  // Co-located hosts have no script ref; their storage is wiped by deleteSnapshot.
7740
- await c.var.host.deleteSnapshot(c.get('actor'), preview.tenantId, preview.id);
8153
+ await c.var.host.deleteSnapshot(actor, preview.tenantId, preview.id);
7741
8154
  return { storageStranded };
7742
8155
  };
7743
8156
  /** The preview-login seam (#1704, `preview-auth.ts`): the directory, and each issuer's deployment. */
@@ -8171,23 +8584,23 @@ export function createControlPlaneApi(options) {
8171
8584
  const slug = await resolveVerticalId(c, c.req.param('slug'));
8172
8585
  const v = await verticalOf(c.get('actor'), slug);
8173
8586
  if (!v)
8174
- return { error: `unknown vertical '${slug}'`, status: 404 };
8587
+ return { refused: 'not_found', error: `unknown vertical '${slug}'` };
8175
8588
  if (confinedTenant(p) !== null && v.ownerTenant !== confinedTenant(p)) {
8176
- return { error: 'forbidden', status: 403 };
8589
+ return { refused: 'forbidden', error: 'forbidden' };
8177
8590
  }
8178
8591
  if (v.ownerTenant === null)
8179
- return { error: `vertical '${slug}' has no owner tenant`, status: 409 };
8592
+ return { refused: 'conflict', error: `vertical '${slug}' has no owner tenant` };
8180
8593
  return { tenantId: v.ownerTenant, slug };
8181
8594
  };
8182
8595
  app.post('/verticals/:slug/previews', async (c) => {
8183
8596
  const gate = await previewVertical(c);
8184
8597
  if ('error' in gate)
8185
- return c.json({ error: gate.error }, gate.status);
8598
+ return refuse(c, gate.refused, gate.error);
8186
8599
  const { tenantId, slug } = gate;
8187
8600
  const body = createPreviewBody.parse(await c.req.json());
8188
8601
  const actor = c.get('actor');
8189
8602
  if (body.empty && body.sourceScopeId) {
8190
- return c.json({ error: `pass either --empty (clean-room) or a sourceScopeId, not both` }, 400);
8603
+ return refuse(c, 'validation_failed', `pass either --empty (clean-room) or a sourceScopeId, not both`);
8191
8604
  }
8192
8605
  // Clean-room (#509 (b)): a null source provisions an EMPTY scope — no prod scope needed.
8193
8606
  // Everything else forks the tenant's own active, non-fork scope for this vertical.
@@ -8201,12 +8614,12 @@ export function createControlPlaneApi(options) {
8201
8614
  : null;
8202
8615
  if (!source) {
8203
8616
  if (body.sourceScopeId) {
8204
- return c.json({ error: `no active prod scope ${body.sourceScopeId} for '${slug}'` }, 404);
8617
+ return refuse(c, 'not_found', `no active prod scope ${body.sourceScopeId} for '${slug}'`);
8205
8618
  }
8206
8619
  if (owned.length === 0) {
8207
- return c.json({ error: `no prod scope to fork for '${slug}' — provision one first, or pass empty:true for a clean-room preview` }, 409);
8620
+ return refuse(c, 'conflict', `no prod scope to fork for '${slug}' — provision one first, or pass empty:true for a clean-room preview`);
8208
8621
  }
8209
- return c.json({ error: `'${slug}' has several prod scopes — pass sourceScopeId`, scopes: owned.map((s) => s.id) }, 409);
8622
+ return refuse(c, 'conflict', `'${slug}' has several prod scopes — pass sourceScopeId`, { scopes: owned.map((s) => s.id) });
8210
8623
  }
8211
8624
  }
8212
8625
  try {
@@ -8215,14 +8628,14 @@ export function createControlPlaneApi(options) {
8215
8628
  }
8216
8629
  catch (e) {
8217
8630
  if (e instanceof ControlPlaneError)
8218
- return c.json({ error: e.message }, failedWith(c, e));
8631
+ return relayRefusal(c, e);
8219
8632
  throw e;
8220
8633
  }
8221
8634
  });
8222
8635
  app.get('/verticals/:slug/previews', async (c) => {
8223
8636
  const gate = await previewVertical(c);
8224
8637
  if ('error' in gate)
8225
- return c.json({ error: gate.error }, gate.status);
8638
+ return refuse(c, gate.refused, gate.error);
8226
8639
  const { tenantId, slug } = gate;
8227
8640
  const actor = c.get('actor');
8228
8641
  const previews = (await c.var.admin.listScopes(actor, { tenantId, vertical: slug }))
@@ -8248,7 +8661,7 @@ export function createControlPlaneApi(options) {
8248
8661
  app.delete('/verticals/:slug/previews/:tag', async (c) => {
8249
8662
  const gate = await previewVertical(c);
8250
8663
  if ('error' in gate)
8251
- return c.json({ error: gate.error }, gate.status);
8664
+ return refuse(c, gate.refused, gate.error);
8252
8665
  const { tenantId, slug } = gate;
8253
8666
  const tag = c.req.param('tag');
8254
8667
  const actor = c.get('actor');
@@ -8266,7 +8679,7 @@ export function createControlPlaneApi(options) {
8266
8679
  }
8267
8680
  catch (e) {
8268
8681
  if (e instanceof ControlPlaneError)
8269
- return c.json({ error: e.message }, failedWith(c, e));
8682
+ return relayRefusal(c, e);
8270
8683
  throw e;
8271
8684
  }
8272
8685
  });
@@ -8353,7 +8766,7 @@ export function createControlPlaneApi(options) {
8353
8766
  // not be able to switch itself back on, and the dashboard has no flow that pulls it.
8354
8767
  const switchScheduleRoute = (to) => async (c) => {
8355
8768
  if (confinedTenant(c.get('principal')) !== null) {
8356
- return c.json({ error: 'forbidden: the schedule switch is staff-only' }, 403);
8769
+ return refuse(c, 'forbidden', 'forbidden: the schedule switch is staff-only');
8357
8770
  }
8358
8771
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
8359
8772
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
@@ -8361,7 +8774,7 @@ export function createControlPlaneApi(options) {
8361
8774
  const actor = c.get('actor');
8362
8775
  // K-3 first, so a scope of another tenant reads as absent before anything is reached.
8363
8776
  if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId))) {
8364
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
8777
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
8365
8778
  }
8366
8779
  const input = { moduleId: body.moduleId, node: { tenantId, scopeId }, reason: body.reason };
8367
8780
  return c.json(to === 'off' ? await c.var.admin.revokeFromSystem(actor, input) : await c.var.admin.restoreToSystem(actor, input));
@@ -8375,14 +8788,14 @@ export function createControlPlaneApi(options) {
8375
8788
  // `confinedTenant` check, not a kind test.
8376
8789
  app.get('/tenants/:tenantId/scopes/:scopeId/system-grants', async (c) => {
8377
8790
  if (confinedTenant(c.get('principal')) !== null) {
8378
- return c.json({ error: 'forbidden: the schedule switch is staff-only' }, 403);
8791
+ return refuse(c, 'forbidden', 'forbidden: the schedule switch is staff-only');
8379
8792
  }
8380
8793
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
8381
8794
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
8382
8795
  const actor = c.get('actor');
8383
8796
  // K-3 first, so a scope of another tenant reads as absent before anything is reached.
8384
8797
  if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId))) {
8385
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
8798
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
8386
8799
  }
8387
8800
  return c.json(await c.var.admin.systemGrantsStatus(actor, { tenantId, scopeId }));
8388
8801
  });
@@ -8397,7 +8810,7 @@ export function createControlPlaneApi(options) {
8397
8810
  // it must not start answering if this path is ever allowlisted for a narrowed read.
8398
8811
  app.get('/system-switches', async (c) => {
8399
8812
  if (confinedTenant(c.get('principal')) !== null) {
8400
- return c.json({ error: 'forbidden: the schedule switch is staff-only' }, 403);
8813
+ return refuse(c, 'forbidden', 'forbidden: the schedule switch is staff-only');
8401
8814
  }
8402
8815
  const q = systemSwitchesQuery.parse({
8403
8816
  position: c.req.query('position'),
@@ -8435,12 +8848,12 @@ export function createControlPlaneApi(options) {
8435
8848
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
8436
8849
  const pin = confinedTenant(c.get('principal'));
8437
8850
  if (pin !== null && pin !== tenantId)
8438
- return c.json({ error: 'forbidden' }, 403);
8851
+ return refuse(c, 'forbidden', 'forbidden');
8439
8852
  const body = peerSwitchBody.parse(await c.req.json());
8440
8853
  const actor = c.get('actor');
8441
8854
  // K-3 first, so a scope of another tenant reads as absent before anything is reached.
8442
8855
  if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId))) {
8443
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
8856
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
8444
8857
  }
8445
8858
  const input = { vertical: body.vertical, node: { tenantId, scopeId }, reason: body.reason };
8446
8859
  return c.json(to === 'off' ? await c.var.admin.revokeFromPeer(actor, input) : await c.var.admin.restoreToPeer(actor, input));
@@ -8455,10 +8868,10 @@ export function createControlPlaneApi(options) {
8455
8868
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
8456
8869
  const pin = confinedTenant(c.get('principal'));
8457
8870
  if (pin !== null && pin !== tenantId)
8458
- return c.json({ error: 'forbidden' }, 403);
8871
+ return refuse(c, 'forbidden', 'forbidden');
8459
8872
  const actor = c.get('actor');
8460
8873
  if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId))) {
8461
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
8874
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
8462
8875
  }
8463
8876
  return c.json(await c.var.admin.peerGrantsStatus(actor, { tenantId, scopeId }));
8464
8877
  });
@@ -8471,10 +8884,10 @@ export function createControlPlaneApi(options) {
8471
8884
  const vertical = c.req.query('vertical');
8472
8885
  const pin = confinedTenant(c.get('principal'));
8473
8886
  if (pin !== null && pin !== tenantId)
8474
- return c.json({ error: 'forbidden' }, 403);
8887
+ return refuse(c, 'forbidden', 'forbidden');
8475
8888
  const actor = c.get('actor');
8476
8889
  if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId)))
8477
- return c.json({ error: 'app not found' }, 404);
8890
+ return refuse(c, 'not_found', 'app not found');
8478
8891
  return c.json(vertical === undefined
8479
8892
  ? await c.var.admin.peerBindings(actor, tenantId, scopeId)
8480
8893
  : await c.var.admin.peerBinding(actor, tenantId, scopeId, verticalSlug.parse(vertical)) ?? null);
@@ -8486,11 +8899,11 @@ export function createControlPlaneApi(options) {
8486
8899
  // The target choice is the tenant's alone. Staff may inspect it through GET, but cannot
8487
8900
  // choose a tenant's site on its behalf.
8488
8901
  if (pin !== tenantId)
8489
- return c.json({ error: 'forbidden' }, 403);
8902
+ return refuse(c, 'forbidden', 'forbidden');
8490
8903
  const body = peerBindingBody.parse(await c.req.json());
8491
8904
  const actor = c.get('actor');
8492
8905
  if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId)))
8493
- return c.json({ error: 'app not found' }, 404);
8906
+ return refuse(c, 'not_found', 'app not found');
8494
8907
  const operationId = ulid();
8495
8908
  const base = { tenantId, scopeId, operationId, vertical: body.vertical, targetScopeId: body.targetScopeId };
8496
8909
  const done = await auditedChange({
@@ -8605,16 +9018,16 @@ export function createControlPlaneApi(options) {
8605
9018
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
8606
9019
  const pin = confinedTenant(c.get('principal'));
8607
9020
  if (pin !== null && pin !== tenantId)
8608
- return c.json({ error: 'forbidden' }, 403);
9021
+ return refuse(c, 'forbidden', 'forbidden');
8609
9022
  const raw = await c.req.json().catch(() => ({}));
8610
9023
  const refused = importCursorAcknowledgementMissing(raw);
8611
9024
  if (refused)
8612
- return c.json({ error: refused }, 400);
9025
+ return refuse(c, 'validation_failed', refused);
8613
9026
  const move = importCursorMove.parse(raw);
8614
9027
  const actor = c.get('actor');
8615
9028
  // K-3 first, so a scope of another tenant reads as absent before anything is reached.
8616
9029
  if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId))) {
8617
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
9030
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
8618
9031
  }
8619
9032
  return c.json(await c.var.admin.moveImportCursor(actor, tenantId, scopeId, move));
8620
9033
  });
@@ -8627,7 +9040,7 @@ export function createControlPlaneApi(options) {
8627
9040
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
8628
9041
  const pin = confinedTenant(c.get('principal'));
8629
9042
  if (pin !== null && pin !== tenantId)
8630
- return c.json({ error: 'forbidden' }, 403);
9043
+ return refuse(c, 'forbidden', 'forbidden');
8631
9044
  const focus = c.req.query('scopeId');
8632
9045
  return c.json(await crossVerticalHealth(c.var.host, {
8633
9046
  actor: c.get('actor'),
@@ -8659,7 +9072,7 @@ export function createControlPlaneApi(options) {
8659
9072
  const input = issueStatusUpdate.parse(await c.req.json());
8660
9073
  const updated = await c.var.admin.setIssueStatus(c.get('actor'), input.fingerprint, input.status);
8661
9074
  if (!updated)
8662
- return c.json({ error: 'unknown issue fingerprint' }, 404);
9075
+ return refuse(c, 'not_found', 'unknown issue fingerprint');
8663
9076
  return c.json(updated);
8664
9077
  });
8665
9078
  // -- findings (#1748): the tenant inbox, with a lifecycle and suppress rules ----
@@ -8682,17 +9095,17 @@ export function createControlPlaneApi(options) {
8682
9095
  app.put('/tenants/:tenantId/findings/:id/status', async (c) => {
8683
9096
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
8684
9097
  if (outsideTenant(c.get('principal'), tenantId))
8685
- return c.json({ error: 'forbidden' }, 403);
9098
+ return refuse(c, 'forbidden', 'forbidden');
8686
9099
  const { status } = findingStatusUpdate.parse(await c.req.json());
8687
9100
  const updated = await c.var.admin.setFindingStatus(c.get('actor'), tenantId, c.req.param('id'), status);
8688
9101
  if (!updated)
8689
- return c.json({ error: 'unknown finding' }, 404);
9102
+ return refuse(c, 'not_found', 'unknown finding');
8690
9103
  return c.json(updated);
8691
9104
  });
8692
9105
  app.get('/tenants/:tenantId/finding-rules', async (c) => {
8693
9106
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
8694
9107
  if (outsideTenant(c.get('principal'), tenantId))
8695
- return c.json({ error: 'forbidden' }, 403);
9108
+ return refuse(c, 'forbidden', 'forbidden');
8696
9109
  const filter = findingRulesQuery.parse({ active: c.req.query('active'), limit: c.req.query('limit') });
8697
9110
  const entries = await c.var.admin.listFindingRules(c.get('actor'), tenantId, filter);
8698
9111
  return c.json({ entries });
@@ -8702,17 +9115,17 @@ export function createControlPlaneApi(options) {
8702
9115
  app.post('/tenants/:tenantId/finding-rules', async (c) => {
8703
9116
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
8704
9117
  if (outsideTenant(c.get('principal'), tenantId))
8705
- return c.json({ error: 'forbidden' }, 403);
9118
+ return refuse(c, 'forbidden', 'forbidden');
8706
9119
  const input = findingRuleInput.parse(await c.req.json());
8707
9120
  return c.json(await c.var.admin.createFindingRule(c.get('actor'), tenantId, input), 201);
8708
9121
  });
8709
9122
  app.delete('/tenants/:tenantId/finding-rules/:ruleId', async (c) => {
8710
9123
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
8711
9124
  if (outsideTenant(c.get('principal'), tenantId))
8712
- return c.json({ error: 'forbidden' }, 403);
9125
+ return refuse(c, 'forbidden', 'forbidden');
8713
9126
  const revoked = await c.var.admin.revokeFindingRule(c.get('actor'), tenantId, c.req.param('ruleId'));
8714
9127
  if (!revoked)
8715
- return c.json({ error: 'unknown finding rule' }, 404);
9128
+ return refuse(c, 'not_found', 'unknown finding rule');
8716
9129
  return c.json(revoked);
8717
9130
  });
8718
9131
  // -- model usage (#1054): meter 3, the one D-30 could not compute -------------
@@ -8748,6 +9161,243 @@ export function createControlPlaneApi(options) {
8748
9161
  const summary = await c.var.admin.summarizeModelUsage(c.get('actor'), { ...(q.tenantId ? { tenantId: q.tenantId } : {}), since, until }, options.modelMarginPercent ?? DEFAULT_MODEL_MARGIN_PERCENT);
8749
9162
  return c.json(summary);
8750
9163
  });
9164
+ // -- store-driver releases (D-63, #2300) ----------------------------------------
9165
+ // Staff only: none of these is on the builder or the tenant allowlist. A write is also made by a
9166
+ // person: `humanStaffActor` must name the same actor from a human staff sign-in, so the
9167
+ // automation token, which resolves to a staff actor as well, publishes nothing.
9168
+ const registeredDriver = (driver) => {
9169
+ const registration = (options.storeDrivers ?? STORE_DRIVERS).find((k) => k.driver === driver);
9170
+ if (!registration)
9171
+ throw new ControlPlaneError(403, `scope store driver '${driver}' is not registered`);
9172
+ return registration;
9173
+ };
9174
+ const releaseFlow = () => {
9175
+ if (!options.storeReleases || !options.ensureScopeStores) {
9176
+ throw new ControlPlaneError(501, 'this control plane keeps no store driver releases');
9177
+ }
9178
+ return { flow: options.storeReleases, ensureScopeStores: options.ensureScopeStores };
9179
+ };
9180
+ /**
9181
+ * A write made by a person, on purpose. The staff session also rides a cookie, which a browser
9182
+ * sends on a same-site request a page fires, so these writes take JSON only (a form or a
9183
+ * `text/plain` beacon cannot set it without a CORS preflight, 415) and refuse a request a
9184
+ * browser marks as from another origin (403). A request with neither header is the CLI's.
9185
+ */
9186
+ const byAPerson = async (c) => {
9187
+ if (!/^application\/json\s*(;|$)/i.test(c.req.header('content-type') ?? '')) {
9188
+ throw new ControlPlaneError(415, 'a store driver release write takes application/json');
9189
+ }
9190
+ const origin = c.req.header('origin');
9191
+ const site = c.req.header('sec-fetch-site');
9192
+ if ((origin && origin !== new URL(c.req.url).origin) || (site && site !== 'same-origin' && site !== 'none')) {
9193
+ throw new ControlPlaneError(403, 'a store driver release write is not taken from another origin');
9194
+ }
9195
+ const actor = c.get('actor');
9196
+ const person = options.humanStaffActor ? await options.humanStaffActor(c.req.raw) : null;
9197
+ if (!person || person !== actor) {
9198
+ throw new ControlPlaneError(403, 'a store driver release is published by a person: this takes a staff sign-in, not a service token');
9199
+ }
9200
+ return actor;
9201
+ };
9202
+ /** The pin a release is run under, or a refusal: the platform runs only what this repo pins. */
9203
+ const pinOf = async (c, registration, sha256) => {
9204
+ const stored = await c.var.admin.getStoreDriverRelease(c.get('actor'), registration.driver, sha256);
9205
+ if (!stored)
9206
+ throw new ControlPlaneError(404, `release ${sha256} of '${registration.driver}' is not stored`);
9207
+ const pin = registration.releases.find((p) => p.sha256 === sha256);
9208
+ if (!pin) {
9209
+ throw new ControlPlaneError(422, `release ${sha256} of '${registration.driver}' is not pinned in its registration; it runs once a reviewed change pins it and the control plane is deployed`);
9210
+ }
9211
+ if (pin.sourceCommit !== stored.sourceCommit) {
9212
+ throw new ControlPlaneError(422, `release ${sha256} is pinned at ${pin.sourceCommit} and was stored as built from ${stored.sourceCommit}`);
9213
+ }
9214
+ return pin;
9215
+ };
9216
+ const rollOut = async (c, registration, sha256, action) => {
9217
+ const { flow, ensureScopeStores } = releaseFlow();
9218
+ const actor = c.get('actor');
9219
+ const loadRelease = storedReleaseLoader({ admin: c.var.admin, actor, bytes: flow.bytes });
9220
+ const result = await runStoreDriverRollout({
9221
+ admin: c.var.admin,
9222
+ actor,
9223
+ registration,
9224
+ sha256,
9225
+ action,
9226
+ ensure: async (input) => {
9227
+ const result = await ensureScopeStores({ ...input, loadRelease });
9228
+ return result ? result.uploaded > 0 : true;
9229
+ },
9230
+ ensureCanary: (to) => flow.ensureCanary({ driver: registration.driver, to, loadRelease }),
9231
+ callCanary: flow.callCanary,
9232
+ });
9233
+ return c.json(result, result.complete ? 200 : 202);
9234
+ };
9235
+ /** Neither a rollout nor a rollback lowers the stored schema: a driver migrates forward only. */
9236
+ const assertSchemaKept = (registration, from, to) => {
9237
+ if (from === null || from === to.sha256)
9238
+ return;
9239
+ const leaving = registration.releases.find((p) => p.sha256 === from);
9240
+ if (!leaving)
9241
+ throw new ControlPlaneError(409, `the current release ${from} of '${registration.driver}' is not pinned, so its schema cannot be judged`);
9242
+ if (to.schema < leaving.schema) {
9243
+ throw new ControlPlaneError(409, `release ${to.sha256} has schema ${to.schema}, below the current release's ${leaving.schema}: objects may already have migrated`);
9244
+ }
9245
+ };
9246
+ /** Where the platform's rebuild of every script of the driver stands (#2309), if it rebuilds them. */
9247
+ const rebuildView = async (admin, actor, driver, known) => {
9248
+ const fingerprint = known ?? (await options.storeReleases?.fingerprint?.());
9249
+ if (!fingerprint)
9250
+ return undefined;
9251
+ const [walk, declared] = await Promise.all([admin.storeDriverRebuild(actor, driver), admin.storeDriverDeclarations(actor, driver)]);
9252
+ const verticals = [...new Set([...declared.verticals.map((v) => v.vertical), ...declared.unreadable])].sort();
9253
+ return { fingerprint, ...storeRebuildStatus(walk, fingerprint, verticals, options.storeReleases?.settleMs), walk: walk ?? null };
9254
+ };
9255
+ app.get('/store-drivers/:driver', async (c) => {
9256
+ const registration = registeredDriver(c.req.param('driver'));
9257
+ const state = await c.var.admin.storeDriverState(c.get('actor'), registration.driver);
9258
+ const rebuild = await rebuildView(c.var.admin, c.get('actor'), registration.driver);
9259
+ return c.json({ driver: registration.driver, pins: registration.releases, ...state, ...(rebuild ? { rebuild } : {}) });
9260
+ });
9261
+ // Store a release's bytes, before or after its pin lands (D-63). Idempotent per bundle.
9262
+ app.put('/store-drivers/:driver/releases', async (c) => {
9263
+ const registration = registeredDriver(c.req.param('driver'));
9264
+ const actor = await byAPerson(c);
9265
+ const { flow } = releaseFlow();
9266
+ const body = await readCappedJson(c.req.raw, STORE_RELEASE_REQUEST_MAX_BYTES);
9267
+ const release = await storeDriverRelease({ admin: c.var.admin, actor, bytes: flow.bytes, driver: registration.driver, body });
9268
+ return c.json({ release, pinned: registration.releases.some((p) => p.sha256 === release.sha256 && p.sourceCommit === release.sourceCommit) }, 201);
9269
+ });
9270
+ // Roll a stored, pinned release out: the canary, then every vertical that ever declared the
9271
+ // driver. Posting the same release again converges a rollout that stopped.
9272
+ app.post('/store-drivers/:driver/rollouts', async (c) => {
9273
+ const registration = registeredDriver(c.req.param('driver'));
9274
+ await byAPerson(c);
9275
+ const { sha256 } = z.object({ sha256: z.string().regex(/^[0-9a-f]{64}$/) }).strict().parse(await c.req.json());
9276
+ const pin = await pinOf(c, registration, sha256);
9277
+ const { current } = await c.var.admin.storeDriverState(c.get('actor'), registration.driver);
9278
+ assertSchemaKept(registration, current?.sha256 ?? null, pin);
9279
+ return rollOut(c, registration, sha256, 'rollout');
9280
+ });
9281
+ // Return to the immediately previous good release (D-63), never further and never a guess. After
9282
+ // a rollout that ended diverged, that is the current good release itself: undoing the bad
9283
+ // rollout converges every script back on the release the fleet was healthy on (kernel
9284
+ // `storeDriverStateIn`, `rollbackTarget`).
9285
+ app.post('/store-drivers/:driver/rollback', async (c) => {
9286
+ const registration = registeredDriver(c.req.param('driver'));
9287
+ await byAPerson(c);
9288
+ const { current, rollbackTarget } = await c.var.admin.storeDriverState(c.get('actor'), registration.driver);
9289
+ if (!current || !rollbackTarget)
9290
+ throw new ControlPlaneError(409, `driver '${registration.driver}' has no previous good release to roll back to`);
9291
+ const pin = await pinOf(c, registration, rollbackTarget);
9292
+ assertSchemaKept(registration, current.sha256, pin);
9293
+ return rollOut(c, registration, rollbackTarget, 'rollback');
9294
+ });
9295
+ // Run a pass of the platform's rebuild of every script of the driver now (#2309): the scheduled
9296
+ // pass does the same every quarter hour, and this is for when that is too slow, after a key is
9297
+ // revoked. Same lease and hold as the scheduled pass, so the two never walk the driver at once,
9298
+ // and a rollout or rollback excludes both. Rebuilds at the release each script runs; moves none.
9299
+ app.post('/store-drivers/:driver/rebuild', async (c) => {
9300
+ const registration = registeredDriver(c.req.param('driver'));
9301
+ const actor = await byAPerson(c);
9302
+ // `deployFinished`: the person attests that no control-plane deploy is rolling out, so the walk
9303
+ // this run starts proves every script current once done, without waiting out the settle window.
9304
+ const text = await c.req.text();
9305
+ let raw = {};
9306
+ if (text.trim() !== '') {
9307
+ try {
9308
+ raw = JSON.parse(text);
9309
+ }
9310
+ catch {
9311
+ throw new ControlPlaneError(422, 'the body is not JSON');
9312
+ }
9313
+ }
9314
+ const body = z.object({ deployFinished: z.boolean().optional() }).strict().parse(raw);
9315
+ const { flow, ensureScopeStores } = releaseFlow();
9316
+ if (!flow.fingerprint)
9317
+ throw new ControlPlaneError(501, 'this control plane does not rebuild store scripts');
9318
+ let fingerprint;
9319
+ let ran = {
9320
+ driver: registration.driver,
9321
+ outcome: 'error',
9322
+ walked: [],
9323
+ uploaded: [],
9324
+ ...(body.deployFinished ? { deployFinished: true } : {}),
9325
+ };
9326
+ try {
9327
+ // Inside the audited path: lists that cannot be read refuse the run, and that refusal is audited too.
9328
+ fingerprint = await flow.fingerprint();
9329
+ const [pass] = await runStoreFleetRebuild({
9330
+ admin: c.var.admin,
9331
+ actor,
9332
+ drivers: [registration],
9333
+ fingerprint,
9334
+ bytes: flow.bytes,
9335
+ ensure: ensureScopeStores,
9336
+ ensureCanary: flow.ensureCanary,
9337
+ budget: storeRebuildBudget(STORE_REBUILD_REQUEST_SUBREQUESTS),
9338
+ // A person asking re-walks a driver that is done: after a control plane deploy has finished
9339
+ // rolling out, that walk is the proof no script kept the older lists.
9340
+ reverify: 'always',
9341
+ ...(flow.settleMs === undefined ? {} : { settleMs: flow.settleMs }),
9342
+ ...(body.deployFinished ? { attestedBy: actor } : {}),
9343
+ });
9344
+ const { rebuild: _row, ...result } = pass;
9345
+ ran = { ...ran, ...result };
9346
+ }
9347
+ catch (e) {
9348
+ ran = { ...ran, error: e instanceof Error ? e.message : String(e) };
9349
+ throw e;
9350
+ }
9351
+ finally {
9352
+ // Written however the pass ended, a refused one included: a staff request reached the fleet.
9353
+ await c.var.admin.recordStoreDriverRebuildRun(actor, ran);
9354
+ }
9355
+ if (ran.outcome === 'error')
9356
+ throw new ControlPlaneError(502, `the rebuild of '${registration.driver}' failed: ${ran.error}`);
9357
+ const rebuild = (await rebuildView(c.var.admin, actor, registration.driver, fingerprint));
9358
+ // Scripts remain, or another holder has the driver: post again.
9359
+ const again = ran.outcome === 'paused' || ran.outcome === 'held' || ran.outcome === 'busy';
9360
+ return c.json({ pass: ran, rebuild }, again ? 202 : 200);
9361
+ });
8751
9362
  return app;
8752
9363
  }
9364
+ /**
9365
+ * The most a release upload's body may be: the decoded cap, as base64 (4 bytes per 3), plus room
9366
+ * for the JSON around it. A bigger body is refused (413) before it is read whole.
9367
+ */
9368
+ const STORE_RELEASE_REQUEST_MAX_BYTES = Math.ceil((STORE_RELEASE_MAX_BYTES * 4) / 3) + 1024 * 1024;
9369
+ /** A JSON body read to at most `max` bytes: a declared length over it, or a stream past it, is 413. */
9370
+ async function readCappedJson(request, max) {
9371
+ const declared = Number(request.headers.get('content-length') ?? NaN);
9372
+ if (Number.isFinite(declared) && declared > max)
9373
+ throw new ControlPlaneError(413, `the body is over ${max} bytes`);
9374
+ if (!request.body)
9375
+ throw new ControlPlaneError(422, 'the body is empty');
9376
+ const reader = request.body.getReader();
9377
+ const chunks = [];
9378
+ let total = 0;
9379
+ for (;;) {
9380
+ const { done, value } = await reader.read();
9381
+ if (done)
9382
+ break;
9383
+ total += value.byteLength;
9384
+ if (total > max) {
9385
+ await reader.cancel().catch(() => undefined);
9386
+ throw new ControlPlaneError(413, `the body is over ${max} bytes`);
9387
+ }
9388
+ chunks.push(value);
9389
+ }
9390
+ const bytes = new Uint8Array(total);
9391
+ let offset = 0;
9392
+ for (const chunk of chunks) {
9393
+ bytes.set(chunk, offset);
9394
+ offset += chunk.byteLength;
9395
+ }
9396
+ try {
9397
+ return JSON.parse(new TextDecoder().decode(bytes));
9398
+ }
9399
+ catch {
9400
+ throw new ControlPlaneError(422, 'the body is not JSON');
9401
+ }
9402
+ }
8753
9403
  //# sourceMappingURL=api.js.map