@substrat-run/control-plane-api 0.147.0 → 0.148.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 (57) hide show
  1. package/dist/api.d.ts +56 -1
  2. package/dist/api.d.ts.map +1 -1
  3. package/dist/api.js +801 -379
  4. package/dist/api.js.map +1 -1
  5. package/dist/deploy.d.ts +4 -3
  6. package/dist/deploy.d.ts.map +1 -1
  7. package/dist/deploy.js +3 -3
  8. package/dist/deploy.js.map +1 -1
  9. package/dist/errors.d.ts +19 -1
  10. package/dist/errors.d.ts.map +1 -1
  11. package/dist/errors.js +18 -4
  12. package/dist/errors.js.map +1 -1
  13. package/dist/index.d.ts +10 -2
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +6 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/lake-gateway.d.ts +54 -0
  18. package/dist/lake-gateway.d.ts.map +1 -0
  19. package/dist/lake-gateway.js +158 -0
  20. package/dist/lake-gateway.js.map +1 -0
  21. package/dist/platform-entry.generated.d.ts +1 -1
  22. package/dist/platform-entry.generated.d.ts.map +1 -1
  23. package/dist/platform-entry.generated.js +2 -2
  24. package/dist/platform-entry.generated.js.map +1 -1
  25. package/dist/platform-scope-stores.d.ts +59 -6
  26. package/dist/platform-scope-stores.d.ts.map +1 -1
  27. package/dist/platform-scope-stores.js +117 -16
  28. package/dist/platform-scope-stores.js.map +1 -1
  29. package/dist/r2-sql-engine.d.ts +50 -0
  30. package/dist/r2-sql-engine.d.ts.map +1 -0
  31. package/dist/r2-sql-engine.js +126 -0
  32. package/dist/r2-sql-engine.js.map +1 -0
  33. package/dist/scope-copy-cleanup.d.ts +12 -0
  34. package/dist/scope-copy-cleanup.d.ts.map +1 -1
  35. package/dist/scope-copy-cleanup.js +14 -3
  36. package/dist/scope-copy-cleanup.js.map +1 -1
  37. package/dist/store-entry.d.ts +90 -0
  38. package/dist/store-entry.d.ts.map +1 -0
  39. package/dist/store-entry.generated.d.ts +60 -0
  40. package/dist/store-entry.generated.d.ts.map +1 -0
  41. package/dist/store-entry.generated.js +62 -0
  42. package/dist/store-entry.generated.js.map +1 -0
  43. package/dist/store-entry.js +178 -0
  44. package/dist/store-entry.js.map +1 -0
  45. package/dist/store-releases.d.ts +147 -0
  46. package/dist/store-releases.d.ts.map +1 -0
  47. package/dist/store-releases.js +298 -0
  48. package/dist/store-releases.js.map +1 -0
  49. package/dist/store-script.d.ts +80 -0
  50. package/dist/store-script.d.ts.map +1 -0
  51. package/dist/store-script.js +173 -0
  52. package/dist/store-script.js.map +1 -0
  53. package/dist/vertical-client.d.ts +4 -0
  54. package/dist/vertical-client.d.ts.map +1 -1
  55. package/dist/vertical-client.js +10 -1
  56. package/dist/vertical-client.js.map +1 -1
  57. package/package.json +7 -7
package/dist/api.js CHANGED
@@ -1,6 +1,7 @@
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 { permissionKey, parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, lakeEventsInput, ownerTransferInput, memberInviteInput, memberRoleInput, ASSET_PART_PREFIX, assetHash, STORE_KINDS, 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, 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, errorCodeOf, capabilityFilterQuery, capabilityId as capabilityIdSchema, PLATFORM_FEATURES_HEADER, PLATFORM_FEATURE_SCOPE_SWEEPER, } from '@substrat-run/contracts';
4
5
  import { SCOPE_COPY_LEASE_MS, copyRestoreFence, replayStoreErasures } from '@substrat-run/kernel';
5
6
  import { attributeFailure } from './failure-attribution.js';
6
7
  import { auditedChange, withAuditedOutcomes } from './audited-change.js';
@@ -8,8 +9,9 @@ import { BIND_EXPORT_BREAK_REFUSAL, CARRIED_AWAY_KEY, bindExportBreakRefusal, ca
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 { PUSH_HOLD_TTL_MS, runStoreKindRollout, storeKindRelease, storedReleaseLoader } from './store-releases.js';
14
+ import { assertNoUnreachableScopeCopies, claimScopeScriptReap, drainScopeScriptCopies, listAllScopeScriptCopies, recordStrandedStorage } from './scope-copy-cleanup.js';
13
15
  import { BACKFILL_PAGE_MAX, backfillScopeScriptCopies } from './scope-copy-backfill.js';
14
16
  import { oidcCallbackUrl, retireClientsOfReapedScope, wirePreviewAuth } from './preview-auth.js';
15
17
  import { scopeDeployment, versionReachedAt } from './scope-deployment.js';
@@ -18,7 +20,7 @@ import { reconcileOutcomeOf, reconcileThenReassert, reviewedEntityGrants, switch
18
20
  import { ConnectionRelayError, relayConnectionUpsert } from './connection-relay.js';
19
21
  import { ControlPlaneError } from '@substrat-run/control-plane-client';
20
22
  import { provisionSiblingScope } from './platform-drain.js';
21
- import { mapError } from './errors.js';
23
+ import { NON_PROBLEM_REFUSAL, coded, mapError, relayed } from './errors.js';
22
24
  import { maskDump, maskRecords } from './mask.js';
23
25
  import { createPseudonymizer } from './pseudonymize.js';
24
26
  import { openDump, sealDump } from './seal.js';
@@ -30,12 +32,30 @@ import { assertSandboxContract, verticalUploadBindings, deployManifest, storedDe
30
32
  * labelled `application/json` is not an RFC 9457 problem however well-shaped its body is,
31
33
  * and `/openapi.json` has documented `application/problem+json` on every error response
32
34
  * since phase 1 — this is the half that makes the document true.
35
+ *
36
+ * `carried` is what a route answered beside `error` before it answered a problem document
37
+ * (a connector's `probe`, a preview's `operationId`, …), kept verbatim so no client loses a
38
+ * member. RFC 9457 §3.2 permits extension members; these are not declared per code in
39
+ * `PROBLEM_EXTENSIONS` yet, which is why they are spelled out at each route rather than
40
+ * passed through by a mapper. Spread last on purpose: where a route's own `detail` meant the
41
+ * downstream's reason, it still does.
33
42
  */
34
- function problem(c, answer) {
35
- return c.body(JSON.stringify(answer.body), answer.status, {
43
+ function problem(c, answer, carried) {
44
+ return c.body(JSON.stringify(carried ? { ...answer.body, ...carried } : answer.body), answer.status, {
36
45
  'content-type': PROBLEM_CONTENT_TYPE,
37
46
  });
38
47
  }
48
+ /** A refusal this route decided, by its code (#113): the status follows from the catalog. */
49
+ function refuse(c, code, detail, carried) {
50
+ return problem(c, coded(code, detail), carried);
51
+ }
52
+ /**
53
+ * A refusal answered by status alone, as `about:blank`: one relayed from downstream (a
54
+ * `ControlPlaneError`'s status), or one the closed taxonomy has no code for (501, 422, 502).
55
+ */
56
+ function refuseStatus(c, status, detail, carried) {
57
+ return problem(c, relayed(status, detail), carried);
58
+ }
39
59
  import { backfillDeclaredStores, blobStoreBindings, collectBlobStoreHandles, collectTenantStoreHandles, missingStoresForTenant, tenantStoreBindings, } from './tenant-stores.js';
40
60
  import { mintPushToken, pushActorFor } from './push-token.js';
41
61
  import { TENANT_SERIES_SCOPE_CAP, REQUEST_FACET_KEYS } from './observability.js';
@@ -44,6 +64,8 @@ import { namespacesForScript } from './do-namespaces.js';
44
64
  import { backupDirectoryIfDue } from './directory-backup.js';
45
65
  import { deployedDoClasses, platformSweeperDecision } from './platform-entry.js';
46
66
  import { STORAGE_PAGE_DEFAULT, STORAGE_PAGE_MAX, readStoragePage } from './storage-meter.js';
67
+ import { queryLakeEvents } from './lake-gateway.js';
68
+ import { LakeQueryError } from './r2-sql-engine.js';
47
69
  import { isCustomHostname, validateBindableHostname, } from './custom-hostnames.js';
48
70
  /**
49
71
  * Response headers carrying the CLI version advisory (`ControlPlaneApiOptions.cliAdvisory`).
@@ -104,9 +126,9 @@ class CarrySourceChanged extends ControlPlaneError {
104
126
  this.attempts = attempts;
105
127
  }
106
128
  }
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 } } : {}) };
129
+ /** What a route answers beside the sentence of an `ExportBreakRefused`: the listing. */
130
+ function exportBreaksOf(e) {
131
+ return e instanceof ExportBreakRefused ? { exportBreaks: { affected: e.breaks } } : {};
110
132
  }
111
133
  /**
112
134
  * A delegated facet answer, normalized — and refused when the vertical that produced it
@@ -782,11 +804,15 @@ function messageOfThrow(err) {
782
804
  /** What a 5xx answered with no error object said: the JSON body's `error` (and `detail`), else the status. */
783
805
  async function answeredMessage(res) {
784
806
  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)
807
+ const str = (k) => (typeof body?.[k] === 'string' && body[k] !== '' ? body[k] : null);
808
+ const error = str('error');
809
+ const detail = str('detail');
810
+ // A headline beside a reason (`deploy upload failed` + the upstream's sentence) reads as
811
+ // both. A problem document's `error` is only the deprecated duplicate of its `detail`
812
+ // (#113), so it adds nothing, and once it is gone `detail` alone still names the occurrence.
813
+ if (error && detail && error !== detail)
788
814
  return `${error}: ${detail}`;
789
- return error ?? detail ?? `HTTP ${res.status}`;
815
+ return detail ?? error ?? str('title') ?? `HTTP ${res.status}`;
790
816
  }
791
817
  /**
792
818
  * The audited HTTP surface over `HostAdmin` (control-plane.md §4.5).
@@ -819,33 +845,62 @@ export function createControlPlaneApi(options) {
819
845
  const { host, authenticate, authenticateBuilder, authenticateTenantService } = options;
820
846
  const copyLeaseMs = options.copyLeaseMs ?? SCOPE_COPY_LEASE_MS;
821
847
  /** 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);
848
+ const manifestStores = (c, scope, running) => scopeStoresOf(c.var.admin, c.get('actor'), scope, options.storeKinds, running);
823
849
  /**
824
850
  * The platform's path to a scope's stores, or nothing: no caller is a co-located host whose
825
851
  * own export, restore, wipe and erasure carry its stores, and a scope that declares none has
826
852
  * none to reach. A hosted control plane always passes a caller (`unreachablePlatformScopeStores`
827
853
  * when it has no route), so a declared store is never skipped there.
828
854
  */
829
- const platformStoresOf = async (c, scope) => {
855
+ const platformStoresOf = async (c, scope, running) => {
830
856
  const caller = options.platformScopeStores;
831
857
  if (!caller || !scope.vertical)
832
858
  return undefined;
833
- const stores = await manifestStores(c, scope);
859
+ const stores = await manifestStores(c, scope, running);
834
860
  return stores.length > 0 ? { caller, vertical: scope.vertical, stores } : undefined;
835
861
  };
836
- /** `substrat.wipe` on each declared store, before the scope's own bytes go. */
837
- const wipePlatformStores = async (c, scope) => {
862
+ /**
863
+ * `substrat.wipe` on each declared store, resolved now and called later: a reap resolves its
864
+ * stores, which can refuse (503), before it unroutes the scope, and wipes after (#2285).
865
+ */
866
+ const platformStoresWipe = async (c, scope) => {
838
867
  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 });
868
+ return async () => {
869
+ if (!reach)
870
+ return;
871
+ await reach.caller.wipeStores({ tenantId: scope.tenantId, scopeId: scope.id, vertical: reach.vertical, stores: reach.stores });
872
+ };
842
873
  };
843
- /** `substrat.export` on each declared store, for a backup, a full pull or a fork. */
874
+ /** `substrat.wipe` on each declared store, before the scope's own bytes go. */
875
+ const wipePlatformStores = async (c, scope) => (await platformStoresWipe(c, scope))();
876
+ /**
877
+ * `substrat.export` on each store the lifecycle reaches, for a backup, a full pull or a fork.
878
+ *
879
+ * A dump is only worth taking if it restores, and a restore imports only what the destination's
880
+ * version declares (L8). So a binding only an earlier version declared (#2286) is left out of the
881
+ * dump when its export is empty, which is what an object that was never written, or one already
882
+ * wiped, answers. One that still holds rows refuses the export (409), naming the binding and the
883
+ * way out, rather than produce a dump its own scope could not restore. Wipe, reap and erasure
884
+ * never export, so they reach such a store regardless.
885
+ */
844
886
  const exportPlatformStores = async (c, scope) => {
845
- const reach = await platformStoresOf(c, scope);
846
- if (!reach)
887
+ const caller = options.platformScopeStores;
888
+ if (!caller || !scope.vertical)
847
889
  return undefined;
848
- return reach.caller.exportStores({ tenantId: scope.tenantId, scopeId: scope.id, vertical: reach.vertical, stores: reach.stores });
890
+ const { running, all } = await scopeStoreReachOf(c.var.admin, c.get('actor'), scope, options.storeKinds);
891
+ if (all.length === 0)
892
+ return undefined;
893
+ const exported = await caller.exportStores({ tenantId: scope.tenantId, scopeId: scope.id, vertical: scope.vertical, stores: all });
894
+ const current = new Set(running.map((store) => store.binding));
895
+ const dropped = exported.filter((store) => !current.has(store.binding));
896
+ const held = dropped.find((store) => store.tables.some((table) => table.rows.length > 0));
897
+ if (held) {
898
+ throw new ControlPlaneError(409, `scope ${scope.id}'s store '${held.binding}' was declared by an earlier version of '${scope.vertical}' and ` +
899
+ `still holds data, and the version the scope runs does not declare it, so a dump of it could not be ` +
900
+ `restored. Declare '${held.binding}' as a '${held.kind}' again in a version the scope runs, then export; ` +
901
+ `or reap the scope without a backup, which wipes it`);
902
+ }
903
+ return exported.filter((store) => current.has(store.binding));
849
904
  };
850
905
  /**
851
906
  * `substrat.erase` on the destination's stores for each subject tombstoned in the destination
@@ -901,7 +956,10 @@ export function createControlPlaneApi(options) {
901
956
  return;
902
957
  if (!scope.vertical)
903
958
  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]));
959
+ // The destination's running version only (L8): a binding only an earlier version declared
960
+ // would be imported into an object nothing in the code the scope runs reads.
961
+ const declared = new Map((await runningVersionStoresOf(c.var.admin, c.get('actor'), scope, options.storeKinds))
962
+ .map((d) => [d.binding, d.kind]));
905
963
  for (const store of stores) {
906
964
  const kind = declared.get(store.binding);
907
965
  if (kind === undefined) {
@@ -913,6 +971,33 @@ export function createControlPlaneApi(options) {
913
971
  }
914
972
  };
915
973
  const app = new Hono();
974
+ // Every refusal is a problem document (#113): the tripwire. A route answers a failure
975
+ // through `refuse`/`refuseStatus`/`problem`, and anything else — a hand-rolled
976
+ // `c.json({ error }, 4xx)`, a sub-app's text 404 — is logged here as
977
+ // `control-plane.non-problem-refusal` with the method, the route PATTERN (never the raw
978
+ // path, which carries tenant and scope ids) and the status. It reads one header and never
979
+ // the body, so it costs nothing on the success path and discloses nothing on the failure
980
+ // one. Two mechanisms hold the rule, and they cover different things: this package's test
981
+ // setup fails any test in which the tripwire fires, which covers every refusal a test
982
+ // REACHES; `test/problem-source.test.ts` scans this file, reached or not, and refuses any
983
+ // response it builds with a failure status outside the problem helpers — and any construct
984
+ // whose status it could not judge (`c.status(`, `c.newResponse(`, `c.html(`, a generic
985
+ // `c.json<T>(`, a `Response` init it cannot read). In production the tripwire is the line
986
+ // an operator searches the worker's logs for, should a client report a refusal it could
987
+ // not parse.
988
+ //
989
+ // Registered first, so it sees the answer every other layer settled on, `onError`'s included.
990
+ app.use('*', async (c, next) => {
991
+ await next();
992
+ if (c.res.status >= 400 && !(c.res.headers.get('content-type') ?? '').startsWith(PROBLEM_CONTENT_TYPE)) {
993
+ console.warn(NON_PROBLEM_REFUSAL, { method: c.req.method, route: c.req.routePath, status: c.res.status });
994
+ }
995
+ });
996
+ // A request no route matches is a refusal too, and Hono's default answers it as text. This
997
+ // answers only where this app is the top-level one (the node server, a self-host): Hono never
998
+ // calls a MOUNTED sub-app's `notFound`, so under the hosted worker's `app.route('/api', …)`
999
+ // the worker's own fallback still answers an unmatched path.
1000
+ app.notFound((c) => refuse(c, 'not_found', 'no such route'));
916
1001
  // The 5xx backstop: every 5xx this API answers lands an ops-failure row, however it was
917
1002
  // answered. A 5xx is the PLATFORM failing (an unmapped throw, or a downstream vertical's own
918
1003
  // 5xx passing through, #559), so the console must be able to list it and a `reference = <id>`
@@ -920,7 +1005,7 @@ export function createControlPlaneApi(options) {
920
1005
  // that catch a ControlPlaneError and answer it themselves: a preview re-push whose bind failed
921
1006
  // after a clean carry answered 502 with a reference and left no row behind.
922
1007
  //
923
- // Registered first, so it wraps every other middleware and route. 501 stays out: an honest
1008
+ // Registered ahead of every middleware but the tripwire, so it wraps every route. 501 stays out: an honest
924
1009
  // not-implemented is a capability statement, not a failure. 4xx stay out too: they are
925
1010
  // refusals the caller can already read. Actor is unset only when the request failed before
926
1011
  // authentication, where nothing worth recording refuses. A throw a step already recorded,
@@ -1013,7 +1098,7 @@ export function createControlPlaneApi(options) {
1013
1098
  }
1014
1099
  }
1015
1100
  if (!principal)
1016
- return c.json({ error: 'unauthenticated' }, 401);
1101
+ return refuse(c, 'unauthenticated', 'unauthenticated');
1017
1102
  c.set('principal', principal);
1018
1103
  c.set('actor', principal.actor);
1019
1104
  // #977: a tenant credential minted for a person writes through a view that names
@@ -1127,7 +1212,7 @@ export function createControlPlaneApi(options) {
1127
1212
  if (c.get('principal').kind === 'builder') {
1128
1213
  const allowed = BUILDER_ROUTES.some((r) => r.method === c.req.method && r.re.test(c.req.path));
1129
1214
  if (!allowed)
1130
- return c.json({ error: 'forbidden' }, 403);
1215
+ return refuse(c, 'forbidden', 'forbidden');
1131
1216
  }
1132
1217
  await next();
1133
1218
  });
@@ -1137,25 +1222,25 @@ export function createControlPlaneApi(options) {
1137
1222
  return next();
1138
1223
  const pin = tenantCredentialPin(c.req.method, c.req.path);
1139
1224
  if (!pin)
1140
- return c.json({ error: 'forbidden' }, 403);
1225
+ return refuse(c, 'forbidden', 'forbidden');
1141
1226
  // The sweep: anything naming a tenant must name THIS one. `x-substrat-tenant` is
1142
1227
  // included, which is what stops the pre-#977 shape — a fleet credential plus a
1143
1228
  // caller-asserted tenant header — from surviving as a way to ask for another.
1144
1229
  if (namedTenants(c).some((t) => t !== p.tenantId)) {
1145
- return c.json({ error: 'forbidden: outside this credential’s tenant' }, 403);
1230
+ return refuse(c, 'forbidden', 'forbidden: outside this credential’s tenant');
1146
1231
  }
1147
1232
  // The route's own pin must be PRESENT, not merely consistent. A missing filter is
1148
1233
  // what makes these routes answer fleet-wide, so absence is the refusal.
1149
1234
  if (pin === 'path' && pathTenant(c.req.path) !== p.tenantId) {
1150
- return c.json({ error: 'forbidden: outside this credential’s tenant' }, 403);
1235
+ return refuse(c, 'forbidden', 'forbidden: outside this credential’s tenant');
1151
1236
  }
1152
1237
  if (pin === 'query' && c.req.query('tenantId') !== p.tenantId) {
1153
- return c.json({ error: 'forbidden: this credential must name its own tenant' }, 403);
1238
+ return refuse(c, 'forbidden', 'forbidden: this credential must name its own tenant');
1154
1239
  }
1155
1240
  if (pin === 'catalog') {
1156
1241
  const named = c.req.query('ownerTenant') ?? c.req.query('visibleTo');
1157
1242
  if (named !== p.tenantId) {
1158
- return c.json({ error: 'forbidden: this credential must name its own tenant' }, 403);
1243
+ return refuse(c, 'forbidden', 'forbidden: this credential must name its own tenant');
1159
1244
  }
1160
1245
  }
1161
1246
  if (pin === 'body') {
@@ -1165,7 +1250,7 @@ export function createControlPlaneApi(options) {
1165
1250
  const body = (await c.req.json().catch(() => null));
1166
1251
  const named = body && typeof body === 'object' ? (body['tenantId'] ?? body['id']) : undefined;
1167
1252
  if (named !== p.tenantId) {
1168
- return c.json({ error: 'forbidden: this credential must name its own tenant' }, 403);
1253
+ return refuse(c, 'forbidden', 'forbidden: this credential must name its own tenant');
1169
1254
  }
1170
1255
  }
1171
1256
  await next();
@@ -1235,6 +1320,8 @@ export function createControlPlaneApi(options) {
1235
1320
  c.set('failure', e);
1236
1321
  return e.status;
1237
1322
  };
1323
+ /** A caught `ControlPlaneError` answered as itself — its status and sentence, `about:blank` — and marked for the backstop. */
1324
+ const relayRefusal = (c, e, carried) => refuseStatus(c, failedWith(c, e), e.message, carried);
1238
1325
  // Rides out a transient downstream window: the install path's binding-attach →
1239
1326
  // script-settings propagation race (#424 case 2), and a one-shot DO storage blip
1240
1327
  // during an export→restore or snapshot copy (#559 (2)). Retrying is cheap at THESE
@@ -1272,9 +1359,9 @@ export function createControlPlaneApi(options) {
1272
1359
  return problem(c, { status: 400, body: toProblem(err, c.req.path) });
1273
1360
  }
1274
1361
  if (err instanceof ExportBreakRefused)
1275
- return c.json(exportBreakBody(err), 409);
1362
+ return refuse(c, 'conflict', err.message, exportBreaksOf(err));
1276
1363
  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);
1364
+ return refuse(c, 'conflict', err.message, { carrySourceChanged: { scopeId: err.scopeId, from: err.from, to: err.to, attempts: err.attempts } });
1278
1365
  }
1279
1366
  const { status, body } = mapError(err);
1280
1367
  // The 5xx backstop (registered first, above) records it, with this throw as the cause.
@@ -1318,7 +1405,7 @@ export function createControlPlaneApi(options) {
1318
1405
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
1319
1406
  const tenant = await c.var.admin.getTenant(c.get('actor'), tenantId);
1320
1407
  if (!tenant)
1321
- return c.json({ error: `unknown tenant: ${tenantId}` }, 404);
1408
+ return refuse(c, 'not_found', `unknown tenant: ${tenantId}`);
1322
1409
  return c.json(tenant);
1323
1410
  });
1324
1411
  // Display rename only — the slug stays put (registry ids `<tenantSlug>/<name>` and
@@ -1355,11 +1442,9 @@ export function createControlPlaneApi(options) {
1355
1442
  const { backup: wantsBackup } = await readJsonBody(c, reapScopeBody);
1356
1443
  const tenant = await c.var.admin.getTenant(actor, tenantId);
1357
1444
  if (!tenant)
1358
- return c.json({ error: `unknown tenant: ${tenantId}` }, 404);
1445
+ return refuse(c, 'not_found', `unknown tenant: ${tenantId}`);
1359
1446
  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);
1447
+ return refuse(c, 'conflict', `tenant ${tenantId} is ${tenant.status}, not deleting — only a deleting tenant may be reaped`);
1363
1448
  }
1364
1449
  try {
1365
1450
  for (const scope of await c.var.admin.listScopes(actor, { tenantId })) {
@@ -1390,7 +1475,7 @@ export function createControlPlaneApi(options) {
1390
1475
  }
1391
1476
  catch (e) {
1392
1477
  if (e instanceof ControlPlaneError) {
1393
- return c.json({ error: e.message }, failedWith(c, e));
1478
+ return relayRefusal(c, e);
1394
1479
  }
1395
1480
  throw e;
1396
1481
  }
@@ -1412,10 +1497,10 @@ export function createControlPlaneApi(options) {
1412
1497
  // `builder`, a plan-gated feature, a key no vertical asked for. And never the plan
1413
1498
  // half: an expiry, a quota or a plan name is pricing, which is not the tenant's.
1414
1499
  if (plan.expiresAt != null || plan.quota != null || plan.plan != null) {
1415
- return c.json({ error: 'a tenant credential grants entitlements without plan fields' }, 403);
1500
+ return refuse(c, 'forbidden', 'a tenant credential grants entitlements without plan fields');
1416
1501
  }
1417
1502
  if (!(await tenantGrantable(p, key))) {
1418
- return c.json({ error: `entitlement '${key}' is not declared by a vertical this tenant can install` }, 403);
1503
+ return refuse(c, 'forbidden', `entitlement '${key}' is not declared by a vertical this tenant can install`);
1419
1504
  }
1420
1505
  }
1421
1506
  await c.var.admin.grantEntitlement(c.get('actor'), tenantId, key, plan);
@@ -1666,7 +1751,7 @@ export function createControlPlaneApi(options) {
1666
1751
  // worse this week" is the question it exists for.
1667
1752
  app.get('/connections/calls', async (c) => {
1668
1753
  if (!options.observability?.connectorCallsSeries) {
1669
- return c.json({ error: 'connector-call analytics are not configured on this control plane' }, 501);
1754
+ return refuseStatus(c, 501, 'connector-call analytics are not configured on this control plane');
1670
1755
  }
1671
1756
  const input = z
1672
1757
  .object({
@@ -1683,7 +1768,7 @@ export function createControlPlaneApi(options) {
1683
1768
  // credential can reach only this path, never the fleet-wide sibling above.
1684
1769
  app.get('/tenants/:tenantId/connections/calls', async (c) => {
1685
1770
  if (!options.observability?.connectorCallsSeries) {
1686
- return c.json({ error: 'connector-call analytics are not configured on this control plane' }, 501);
1771
+ return refuseStatus(c, 501, 'connector-call analytics are not configured on this control plane');
1687
1772
  }
1688
1773
  const input = z.object({
1689
1774
  tenantId: tenantIdSchema,
@@ -1780,7 +1865,7 @@ export function createControlPlaneApi(options) {
1780
1865
  const raw = await c.req.json();
1781
1866
  const body = typeof raw === 'object' && raw !== null ? raw : {};
1782
1867
  if (body.tenantId !== undefined && body.tenantId !== tenantId) {
1783
- return c.json({ error: 'body tenantId disagrees with the route tenant' }, 400);
1868
+ return refuse(c, 'validation_failed', 'body tenantId disagrees with the route tenant');
1784
1869
  }
1785
1870
  try {
1786
1871
  return c.json(await relayConnectionUpsert(c.var.host, c.get('actor'), { ...body, tenantId }, { probeCandidate, prepareCandidate }));
@@ -1789,7 +1874,7 @@ export function createControlPlaneApi(options) {
1789
1874
  if (err instanceof ConnectionRelayError && err.status < 500) {
1790
1875
  // A refusal carries the provider's own answer, so a console can show WHY rather
1791
1876
  // than "save failed" — the whole point of checking before writing.
1792
- return c.json({ error: err.message, ...(err.probe ? { probe: err.probe } : {}) }, err.status);
1877
+ return refuseStatus(c, err.status, err.message, err.probe ? { probe: err.probe } : undefined);
1793
1878
  }
1794
1879
  // A 5xx relay refusal (#603: this deployment holds no seal key) is the PLATFORM
1795
1880
  // failing, not the caller — rethrown so the shared boundary maps it (still a 503
@@ -1808,7 +1893,7 @@ export function createControlPlaneApi(options) {
1808
1893
  const rows = await c.var.admin.listConnections(c.get('actor'), { tenantId, includeRevoked: true });
1809
1894
  const row = rows.find((r) => r.id === id);
1810
1895
  if (!row)
1811
- return c.json({ error: 'unknown connection' }, 404);
1896
+ return refuse(c, 'not_found', 'unknown connection');
1812
1897
  if (row.status !== 'revoked')
1813
1898
  await c.var.admin.revokeConnection(c.get('actor'), row.id);
1814
1899
  return c.body(null, 204);
@@ -1846,7 +1931,7 @@ export function createControlPlaneApi(options) {
1846
1931
  scopeId,
1847
1932
  id: connectLinkId.parse(c.req.param('linkId')),
1848
1933
  });
1849
- return link ? c.json(link) : c.json({ error: 'unknown connect link' }, 404);
1934
+ return link ? c.json(link) : refuse(c, 'not_found', 'unknown connect link');
1850
1935
  });
1851
1936
  // The callback's spend. A refusal is a 200 carrying the typed reason, not an error status:
1852
1937
  // "already used" is the answer to the question asked, and the callback renders each one.
@@ -1885,7 +1970,7 @@ export function createControlPlaneApi(options) {
1885
1970
  scopeId,
1886
1971
  id: connectLinkId.parse(c.req.param('linkId')),
1887
1972
  });
1888
- return link ? c.body(null, 204) : c.json({ error: 'unknown connect link' }, 404);
1973
+ return link ? c.body(null, 204) : refuse(c, 'not_found', 'unknown connect link');
1889
1974
  });
1890
1975
  /**
1891
1976
  * The tenant-scoped connection lookup the two inspection routes share. Same K-3
@@ -1907,6 +1992,79 @@ export function createControlPlaneApi(options) {
1907
1992
  const rows = await c.var.admin.listConnections(c.get('actor'), { tenantId });
1908
1993
  return rows.find((r) => r.id === id);
1909
1994
  };
1995
+ // Staff/service only: deliberately absent from BUILDER_ROUTES and TENANT_ROUTES.
1996
+ // No generic connector-state write and no grant escalation: the connector verifies the
1997
+ // scope's delivered permission again before writing its own binding record.
1998
+ app.get('/tenants/:tenantId/connections/:id/bindings', async (c) => {
1999
+ const row = await inspectableConnection(c);
2000
+ if (!row)
2001
+ return refuse(c, 'not_found', 'unknown connection');
2002
+ const read = options.connectionInspectors?.[row.provider]?.bindings;
2003
+ if (!read)
2004
+ return refuseStatus(c, 501, `no scope binding registered for provider '${row.provider}'`);
2005
+ const bindings = await read(c.var.host, row);
2006
+ return c.json(await Promise.all(bindings.map(async (binding) => {
2007
+ const scope = scopeIdSchema.parse(binding.scopeId);
2008
+ try {
2009
+ const grants = await c.var.host.connectionGrantsInScope(row.tenantId, scope);
2010
+ return { ...binding, permissionHeld: grants.some((g) => g.connectionId === row.id && g.permission === binding.permission), permissionInspectionError: undefined };
2011
+ }
2012
+ catch (error) {
2013
+ // A stored target remains inspectable when its scope is inactive or its
2014
+ // deployment is unavailable. Unknown authority must not read as a missing grant.
2015
+ return { ...binding, permissionHeld: undefined, permissionInspectionError: error instanceof Error ? error.message : String(error) };
2016
+ }
2017
+ })));
2018
+ });
2019
+ app.put('/tenants/:tenantId/connections/:id/bindings/:scopeId', async (c) => {
2020
+ const row = await inspectableConnection(c);
2021
+ if (!row)
2022
+ return refuse(c, 'not_found', 'unknown connection');
2023
+ const bind = options.connectionInspectors?.[row.provider]?.bindScope;
2024
+ if (!bind)
2025
+ return refuseStatus(c, 501, `no scope binding registered for provider '${row.provider}'`);
2026
+ const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
2027
+ const target = await c.var.admin.getScopeRecord(c.get('actor'), row.tenantId, scopeId);
2028
+ if (!target)
2029
+ return refuse(c, 'not_found', 'unknown scope');
2030
+ if (!isPrimaryScope(target))
2031
+ return refuseStatus(c, 403, PREVIEW_CONNECTIONS_REFUSAL);
2032
+ if (target.status !== 'active' || !target.vertical || target.vertical !== row.vertical) {
2033
+ return refuseStatus(c, 409, 'connection and target must share an active vertical scope');
2034
+ }
2035
+ const fields = {
2036
+ operation: z.string().regex(/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/),
2037
+ permission: permissionKey,
2038
+ };
2039
+ const schema = row.provider === 'planima'
2040
+ ? z.object({ ...fields,
2041
+ organizationId: z.number().int().positive().nullable().optional(),
2042
+ currency: z.string().regex(/^[A-Z]{3}$/).optional(),
2043
+ window: z.object({ fromYear: z.number().int(), toYear: z.number().int() })
2044
+ .refine((w) => w.toYear >= w.fromYear, 'window ends before it starts').nullable().optional(),
2045
+ horizonYears: z.number().int().min(0).optional(),
2046
+ }).strict()
2047
+ : z.object(fields).strict();
2048
+ const input = schema.parse(await c.req.json());
2049
+ const grants = await c.var.host.connectionGrantsInScope(row.tenantId, scopeId);
2050
+ if (!grants.some((g) => g.connectionId === row.id && g.permission === input.permission)) {
2051
+ return refuseStatus(c, 409, `connection does not hold '${input.permission}' on scope ${scopeId}; grant it through the tenant's authorized connection flow, then bind`);
2052
+ }
2053
+ const operationId = ulid();
2054
+ const done = await auditedChange({
2055
+ flow: 'connection-binding', operationId,
2056
+ run: () => bind(c.var.host, row, { ...input, tenantId: row.tenantId, scopeId, vertical: target.vertical }),
2057
+ refused: () => false,
2058
+ record: (phase) => c.var.admin.recordConnectionBindingChange(c.get('actor'), {
2059
+ tenantId: row.tenantId, scopeId, connectionId: row.id, provider: row.provider,
2060
+ ...input,
2061
+ operationId, ...(phase.phase === 'applied' ? { phase: 'applied' } : phase),
2062
+ }),
2063
+ });
2064
+ if ('error' in done)
2065
+ throw done.error;
2066
+ return c.json({ ...done.result, permissionHeld: true, ...('unrecorded' in done ? { auditWarning: done.unrecorded } : {}) });
2067
+ });
1910
2068
  /**
1911
2069
  * **Verify a credential against the provider** (#605) — the read that turns "stored"
1912
2070
  * into "works, and for this account".
@@ -1924,10 +2082,10 @@ export function createControlPlaneApi(options) {
1924
2082
  app.post('/tenants/:tenantId/connections/:id/verify', async (c) => {
1925
2083
  const row = await inspectableConnection(c);
1926
2084
  if (!row)
1927
- return c.json({ error: 'unknown connection' }, 404);
2085
+ return refuse(c, 'not_found', 'unknown connection');
1928
2086
  const probe = options.connectionInspectors?.[row.provider]?.probe;
1929
2087
  if (!probe) {
1930
- return c.json({ error: `no probe registered for provider '${row.provider}'` }, 501);
2088
+ return refuseStatus(c, 501, `no probe registered for provider '${row.provider}'`);
1931
2089
  }
1932
2090
  return c.json(connectionProbe.parse(await probe(c.var.host, row)));
1933
2091
  });
@@ -1946,10 +2104,10 @@ export function createControlPlaneApi(options) {
1946
2104
  app.get('/tenants/:tenantId/connections/:id/activity', async (c) => {
1947
2105
  const row = await inspectableConnection(c);
1948
2106
  if (!row)
1949
- return c.json({ error: 'unknown connection' }, 404);
2107
+ return refuse(c, 'not_found', 'unknown connection');
1950
2108
  const activity = options.connectionInspectors?.[row.provider]?.activity;
1951
2109
  if (!activity) {
1952
- return c.json({ error: `no activity view registered for provider '${row.provider}'` }, 501);
2110
+ return refuseStatus(c, 501, `no activity view registered for provider '${row.provider}'`);
1953
2111
  }
1954
2112
  const source = connectionActivitySource.catch('ledger').parse(c.req.query('source'));
1955
2113
  const live = c.req.query('live') === '1';
@@ -1967,10 +2125,10 @@ export function createControlPlaneApi(options) {
1967
2125
  app.get('/tenants/:tenantId/connections/:id/credential', async (c) => {
1968
2126
  const row = await inspectableConnection(c);
1969
2127
  if (!row)
1970
- return c.json({ error: 'unknown connection' }, 404);
2128
+ return refuse(c, 'not_found', 'unknown connection');
1971
2129
  const credential = options.connectionInspectors?.[row.provider]?.credential;
1972
2130
  if (!credential) {
1973
- return c.json({ error: `no credential view registered for provider '${row.provider}'` }, 501);
2131
+ return refuseStatus(c, 501, `no credential view registered for provider '${row.provider}'`);
1974
2132
  }
1975
2133
  return c.json(connectionCredential.parse(await credential(c.var.host, row)));
1976
2134
  });
@@ -1982,10 +2140,10 @@ export function createControlPlaneApi(options) {
1982
2140
  app.get('/tenants/:tenantId/connections/:id/certificate', async (c) => {
1983
2141
  const row = await inspectableConnection(c);
1984
2142
  if (!row)
1985
- return c.json({ error: 'unknown connection' }, 404);
2143
+ return refuse(c, 'not_found', 'unknown connection');
1986
2144
  const certificate = await options.connectionInspectors?.[row.provider]?.certificate?.(c.var.host, row);
1987
2145
  if (!certificate)
1988
- return c.json({ error: 'this connection signs in with no certificate' }, 404);
2146
+ return refuse(c, 'not_found', 'this connection signs in with no certificate');
1989
2147
  return c.json(connectionCertificate.parse(certificate));
1990
2148
  });
1991
2149
  // -- the scope directory (§3.2/§4.2) ---------------------------------------
@@ -2043,7 +2201,7 @@ export function createControlPlaneApi(options) {
2043
2201
  // A builder acts only within its own tenant; a foreign tenant is hidden as 404 (K-3),
2044
2202
  // never distinguished from "no such scope".
2045
2203
  if (outsideTenant(p, tenantId)) {
2046
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${input.parentScopeId})` }, 404);
2204
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${input.parentScopeId})`);
2047
2205
  }
2048
2206
  // The provisioning sequence lives in one place — `provisionSiblingScope` — shared with the
2049
2207
  // platform-intent drain's `provision-sibling` handler (platform-intents.md B2).
@@ -2057,12 +2215,12 @@ export function createControlPlaneApi(options) {
2057
2215
  owner: input.owner,
2058
2216
  });
2059
2217
  if (!result.ok)
2060
- return c.json({ error: result.error }, result.status);
2218
+ return refuseStatus(c, result.status, result.error);
2061
2219
  return c.json(await c.var.admin.getScopeRecord(actor, tenantId, input.scopeId), 201);
2062
2220
  }
2063
2221
  catch (e) {
2064
2222
  if (e instanceof ControlPlaneError)
2065
- return c.json({ error: e.message }, failedWith(c, e));
2223
+ return relayRefusal(c, e);
2066
2224
  throw e;
2067
2225
  }
2068
2226
  });
@@ -2072,12 +2230,12 @@ export function createControlPlaneApi(options) {
2072
2230
  // A builder reads only its own tenant; a foreign tenant is hidden as 404 (K-3).
2073
2231
  const p = c.get('principal');
2074
2232
  if (outsideTenant(p, tenantId)) {
2075
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
2233
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
2076
2234
  }
2077
2235
  const record = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
2078
2236
  // Absent, or present under another tenant — indistinguishable on purpose (K-3).
2079
2237
  if (!record)
2080
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
2238
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
2081
2239
  return c.json(record);
2082
2240
  });
2083
2241
  // Read-only introspection of a scope's own database (kernel-design §5.4 admin-query
@@ -3023,7 +3181,7 @@ export function createControlPlaneApi(options) {
3023
3181
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
3024
3182
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3025
3183
  if (!scope)
3026
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3184
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3027
3185
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'listScopeTables', null, {
3028
3186
  viaVertical: (v) => v.listScopeTables(scopeId),
3029
3187
  colocated: () => c.var.admin.listScopeTables(c.get('actor'), tenantId, scopeId),
@@ -3053,11 +3211,11 @@ export function createControlPlaneApi(options) {
3053
3211
  // A builder reads only its own tenant; a foreign tenant is hidden as 404 (K-3).
3054
3212
  const principal = c.get('principal');
3055
3213
  if (outsideTenant(principal, tenantId)) {
3056
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3214
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3057
3215
  }
3058
3216
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3059
3217
  if (!scope)
3060
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3218
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3061
3219
  // Health reads the scope the same way the Data view does, so it leaves the same
3062
3220
  // row: an auditor should not be able to tell which route reached the tables.
3063
3221
  const tables = await delegatedRead(c, tenantId, scopeId, scope, 'listScopeTables', null, {
@@ -3105,7 +3263,7 @@ export function createControlPlaneApi(options) {
3105
3263
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
3106
3264
  const principal = c.get('principal');
3107
3265
  if (outsideTenant(principal, tenantId)) {
3108
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3266
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3109
3267
  }
3110
3268
  const filter = platformRequestFilter.parse({
3111
3269
  kind: c.req.query('kind'),
@@ -3114,7 +3272,7 @@ export function createControlPlaneApi(options) {
3114
3272
  });
3115
3273
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3116
3274
  if (!scope)
3117
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3275
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3118
3276
  // Same delegation ladder as the table reads: the intents live in the DO of the scope's BOUND
3119
3277
  // version, so a hosted scope is asked through its vertical and a co-located one locally.
3120
3278
  const vertical = await verticalForScope(c, scope);
@@ -3135,17 +3293,17 @@ export function createControlPlaneApi(options) {
3135
3293
  const actor = c.get('actor');
3136
3294
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
3137
3295
  if (!scope)
3138
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3296
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3139
3297
  const p = c.get('principal');
3140
3298
  const scopePin = confinedTenant(p);
3141
3299
  if (scopePin) {
3142
3300
  const v = scope.vertical ? await verticalOf(actor, scope.vertical) : undefined;
3143
3301
  if (!v || v.ownerTenant !== scopePin)
3144
- return c.json({ error: 'forbidden' }, 403);
3302
+ return refuse(c, 'forbidden', 'forbidden');
3145
3303
  }
3146
3304
  const reached = await deploymentForScope(c, scope);
3147
3305
  if (!reached)
3148
- return c.json({ error: await diagnoseUnboundScope(c.get('actor'), scope) }, 501);
3306
+ return refuseStatus(c, 501, await diagnoseUnboundScope(c.get('actor'), scope));
3149
3307
  const vertical = reached.client;
3150
3308
  const entitlements = await c.var.admin.listEntitlements(actor, tenantId);
3151
3309
  // #406: re-gathered and re-delivered like entitlements, so a reconcile also repairs a
@@ -3299,7 +3457,7 @@ export function createControlPlaneApi(options) {
3299
3457
  // unbound store is exactly what makes a vertical refuse its own reconcile), so
3300
3458
  // it must not be the half that gets dropped.
3301
3459
  const detail = storeErrors.length ? `${e.message} (stores: ${storeErrors.join('; ')})` : e.message;
3302
- return c.json({ error: detail }, failedWith(c, e));
3460
+ return refuseStatus(c, failedWith(c, e), detail);
3303
3461
  }
3304
3462
  throw e;
3305
3463
  }
@@ -3321,7 +3479,7 @@ export function createControlPlaneApi(options) {
3321
3479
  });
3322
3480
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3323
3481
  if (!scope)
3324
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3482
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3325
3483
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'facetEvents', input, {
3326
3484
  // #1762: only the vertical's answer needs the guard. The co-located read runs
3327
3485
  // this platform's own kernel, which withholds by construction.
@@ -3390,7 +3548,7 @@ export function createControlPlaneApi(options) {
3390
3548
  });
3391
3549
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3392
3550
  if (!scope)
3393
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3551
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3394
3552
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'eventCause', input, {
3395
3553
  viaVertical: (v) => v.eventCause(scopeId, input),
3396
3554
  colocated: () => c.var.admin.eventCause(c.get('actor'), tenantId, scopeId, input),
@@ -3406,7 +3564,7 @@ export function createControlPlaneApi(options) {
3406
3564
  });
3407
3565
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3408
3566
  if (!scope)
3409
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3567
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3410
3568
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'eventEffects', input, {
3411
3569
  viaVertical: (v) => v.eventEffects(scopeId, input),
3412
3570
  colocated: () => c.var.admin.eventEffects(c.get('actor'), tenantId, scopeId, input),
@@ -3423,7 +3581,7 @@ export function createControlPlaneApi(options) {
3423
3581
  });
3424
3582
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3425
3583
  if (!scope)
3426
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3584
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3427
3585
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'invocationEvents', input, {
3428
3586
  viaVertical: (v) => v.invocationEvents(scopeId, input),
3429
3587
  colocated: () => c.var.admin.invocationEvents(c.get('actor'), tenantId, scopeId, input),
@@ -3440,7 +3598,7 @@ export function createControlPlaneApi(options) {
3440
3598
  });
3441
3599
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3442
3600
  if (!scope)
3443
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3601
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3444
3602
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'deadLetters', input, {
3445
3603
  viaVertical: (v) => v.deadLetters(scopeId, input),
3446
3604
  colocated: () => c.var.admin.deadLetters(c.get('actor'), tenantId, scopeId, input),
@@ -3455,7 +3613,7 @@ export function createControlPlaneApi(options) {
3455
3613
  const input = lifecycleFlowInput.parse(await c.req.json());
3456
3614
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3457
3615
  if (!scope)
3458
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3616
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3459
3617
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'lifecycleFlow', input, {
3460
3618
  viaVertical: (v) => v.lifecycleFlow(scopeId, input),
3461
3619
  colocated: () => c.var.admin.lifecycleFlow(c.get('actor'), tenantId, scopeId, input),
@@ -3470,7 +3628,7 @@ export function createControlPlaneApi(options) {
3470
3628
  const input = operationSeriesInput.parse(await c.req.json());
3471
3629
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3472
3630
  if (!scope)
3473
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3631
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3474
3632
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'operationSeries', input, {
3475
3633
  viaVertical: (v) => v.operationSeries(scopeId, input),
3476
3634
  colocated: () => c.var.admin.operationSeries(c.get('actor'), tenantId, scopeId, input),
@@ -3490,7 +3648,7 @@ export function createControlPlaneApi(options) {
3490
3648
  });
3491
3649
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3492
3650
  if (!scope)
3493
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3651
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3494
3652
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'entityHistory', input, {
3495
3653
  viaVertical: (v) => v.entityHistory(scopeId, input),
3496
3654
  colocated: () => c.var.admin.entityHistory(c.get('actor'), tenantId, scopeId, input),
@@ -3506,7 +3664,7 @@ export function createControlPlaneApi(options) {
3506
3664
  });
3507
3665
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3508
3666
  if (!scope)
3509
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3667
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3510
3668
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'readScopeTable', input, {
3511
3669
  viaVertical: (v) => v.readScopeTable(scopeId, input),
3512
3670
  colocated: () => c.var.admin.readScopeTable(c.get('actor'), tenantId, scopeId, input),
@@ -3523,7 +3681,7 @@ export function createControlPlaneApi(options) {
3523
3681
  const input = queryScopeInput.parse(await c.req.json());
3524
3682
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3525
3683
  if (!scope)
3526
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3684
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3527
3685
  return c.json(await delegatedRead(
3528
3686
  // The SQL is the argument the co-located branch logs, so it is the argument
3529
3687
  // here — an audit row for the read-only console that did not carry the
@@ -3549,12 +3707,12 @@ export function createControlPlaneApi(options) {
3549
3707
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
3550
3708
  const principal = c.get('principal');
3551
3709
  if (outsideTenant(principal, tenantId)) {
3552
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3710
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3553
3711
  }
3554
3712
  const filter = denialLogQuery.parse(c.req.query());
3555
3713
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3556
3714
  if (!scope)
3557
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3715
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3558
3716
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'listDenials', filter, {
3559
3717
  viaVertical: (v) => v.listDenials(scopeId, filter),
3560
3718
  colocated: () => c.var.admin.listDenials(c.get('actor'), tenantId, scopeId, filter),
@@ -3565,12 +3723,12 @@ export function createControlPlaneApi(options) {
3565
3723
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
3566
3724
  const principal = c.get('principal');
3567
3725
  if (outsideTenant(principal, tenantId)) {
3568
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3726
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3569
3727
  }
3570
3728
  const filter = denialLogQuery.parse(c.req.query());
3571
3729
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3572
3730
  if (!scope)
3573
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3731
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3574
3732
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'summarizeDenials', filter, {
3575
3733
  viaVertical: (v) => v.summarizeDenials(scopeId, filter),
3576
3734
  colocated: () => c.var.admin.summarizeDenials(c.get('actor'), tenantId, scopeId, filter),
@@ -3583,14 +3741,14 @@ export function createControlPlaneApi(options) {
3583
3741
  // does not rest on which list the route is absent from. Delegated like the denial log.
3584
3742
  app.get('/tenants/:tenantId/scopes/:scopeId/capabilities', async (c) => {
3585
3743
  if (confinedTenant(c.get('principal')) !== null) {
3586
- return c.json({ error: 'forbidden: the capability directory is staff-only' }, 403);
3744
+ return refuse(c, 'forbidden', 'forbidden: the capability directory is staff-only');
3587
3745
  }
3588
3746
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
3589
3747
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
3590
3748
  const filter = capabilityFilterQuery.parse(c.req.query());
3591
3749
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3592
3750
  if (!scope)
3593
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3751
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3594
3752
  return c.json(await delegatedRead(c, tenantId, scopeId, scope, 'listCapabilities', filter, {
3595
3753
  viaVertical: (v) => v.listCapabilities(scopeId, filter),
3596
3754
  colocated: () => c.var.admin.listCapabilities(c.get('actor'), tenantId, scopeId, filter),
@@ -3604,7 +3762,7 @@ export function createControlPlaneApi(options) {
3604
3762
  // the scope does not hold is 404; a deployment built before the far end answers 501 "redeploy".
3605
3763
  app.post('/tenants/:tenantId/scopes/:scopeId/capabilities/:capabilityId/revoke', async (c) => {
3606
3764
  if (confinedTenant(c.get('principal')) !== null) {
3607
- return c.json({ error: 'forbidden: revoking a capability is staff-only' }, 403);
3765
+ return refuse(c, 'forbidden', 'forbidden: revoking a capability is staff-only');
3608
3766
  }
3609
3767
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
3610
3768
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
@@ -3625,20 +3783,20 @@ export function createControlPlaneApi(options) {
3625
3783
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
3626
3784
  const principal = c.get('principal');
3627
3785
  if (outsideTenant(principal, tenantId)) {
3628
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3786
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3629
3787
  }
3630
3788
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3631
3789
  if (!scope)
3632
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3790
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3633
3791
  const vertical = await verticalForScope(c, scope);
3634
3792
  if (!vertical)
3635
- return c.json({ error: await diagnoseUnboundScope(c.get('actor'), scope) }, 501);
3793
+ return refuseStatus(c, 501, await diagnoseUnboundScope(c.get('actor'), scope));
3636
3794
  try {
3637
3795
  return c.json(await vertical.ownerSeat(tenantId, scopeId));
3638
3796
  }
3639
3797
  catch (e) {
3640
3798
  if (e instanceof ControlPlaneError)
3641
- return c.json({ error: e.message }, failedWith(c, e));
3799
+ return relayRefusal(c, e);
3642
3800
  throw e;
3643
3801
  }
3644
3802
  });
@@ -3660,25 +3818,25 @@ export function createControlPlaneApi(options) {
3660
3818
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
3661
3819
  const principal = c.get('principal');
3662
3820
  if (outsideTenant(principal, tenantId)) {
3663
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3821
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3664
3822
  }
3665
3823
  const actor = c.get('actor');
3666
3824
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
3667
3825
  if (!scope)
3668
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3826
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3669
3827
  const vertical = await verticalForScope(c, scope);
3670
3828
  if (!vertical)
3671
- return c.json({ error: await diagnoseUnboundScope(actor, scope) }, 501);
3829
+ return refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope));
3672
3830
  const origin = await instanceOrigin(c.var.admin, actor, scopeId);
3673
3831
  if (!origin) {
3674
- return c.json({ error: `scope ${scopeId} has no hostname bound — a claim link needs an address to open on` }, 409);
3832
+ return refuse(c, 'conflict', `scope ${scopeId} has no hostname bound — a claim link needs an address to open on`);
3675
3833
  }
3676
3834
  try {
3677
3835
  return c.json(await vertical.mintOwnerClaim({ tenantId, scopeId, origin, actor }), 201);
3678
3836
  }
3679
3837
  catch (e) {
3680
3838
  if (e instanceof ControlPlaneError)
3681
- return c.json({ error: e.message }, failedWith(c, e));
3839
+ return relayRefusal(c, e);
3682
3840
  throw e;
3683
3841
  }
3684
3842
  });
@@ -3706,10 +3864,10 @@ export function createControlPlaneApi(options) {
3706
3864
  // K-3: another tenant's pair, or no such scope, reads the same — absent.
3707
3865
  const scope = outsideTenant(principal, tenantId) ? null : await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
3708
3866
  if (!scope)
3709
- return { refusal: c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404) };
3867
+ return { refusal: refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`) };
3710
3868
  const vertical = await verticalForScope(c, scope);
3711
3869
  if (!vertical)
3712
- return { refusal: c.json({ error: await diagnoseUnboundScope(actor, scope) }, 501) };
3870
+ return { refusal: refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope)) };
3713
3871
  return { tenantId, scopeId, actor, vertical, person: memberPerson(principal) };
3714
3872
  };
3715
3873
  /**
@@ -3724,7 +3882,7 @@ export function createControlPlaneApi(options) {
3724
3882
  const { operationId } = done;
3725
3883
  if ('error' in done) {
3726
3884
  if (done.error instanceof ControlPlaneError) {
3727
- return c.json({ error: done.error.message, operationId }, failedWith(c, done.error));
3885
+ return relayRefusal(c, done.error, { operationId });
3728
3886
  }
3729
3887
  throw done.error;
3730
3888
  }
@@ -3763,7 +3921,7 @@ export function createControlPlaneApi(options) {
3763
3921
  }
3764
3922
  catch (e) {
3765
3923
  if (e instanceof ControlPlaneError)
3766
- return c.json({ error: e.message }, failedWith(c, e));
3924
+ return relayRefusal(c, e);
3767
3925
  throw e;
3768
3926
  }
3769
3927
  });
@@ -3772,11 +3930,11 @@ export function createControlPlaneApi(options) {
3772
3930
  if ('refusal' in target)
3773
3931
  return target.refusal;
3774
3932
  if (!target.person)
3775
- return c.json({ error: NO_PERSON }, 403);
3933
+ return refuse(c, 'forbidden', NO_PERSON);
3776
3934
  const body = memberInviteInput.parse(await c.req.json());
3777
3935
  const origin = await instanceOrigin(c.var.admin, target.actor, target.scopeId);
3778
3936
  if (!origin) {
3779
- return c.json({ error: `scope ${target.scopeId} has no hostname bound — an invite link needs an address to open on` }, 409);
3937
+ return refuse(c, 'conflict', `scope ${target.scopeId} has no hostname bound — an invite link needs an address to open on`);
3780
3938
  }
3781
3939
  const { tenantId, scopeId, person } = target;
3782
3940
  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 +3945,7 @@ export function createControlPlaneApi(options) {
3787
3945
  if ('refusal' in target)
3788
3946
  return target.refusal;
3789
3947
  if (!target.person)
3790
- return c.json({ error: NO_PERSON }, 403);
3948
+ return refuse(c, 'forbidden', NO_PERSON);
3791
3949
  const member = principalIdSchema.parse(c.req.param('principal'));
3792
3950
  const body = memberRoleInput.parse(await c.req.json());
3793
3951
  const { tenantId, scopeId, person } = target;
@@ -3799,7 +3957,7 @@ export function createControlPlaneApi(options) {
3799
3957
  if ('refusal' in target)
3800
3958
  return target.refusal;
3801
3959
  if (!target.person)
3802
- return c.json({ error: NO_PERSON }, 403);
3960
+ return refuse(c, 'forbidden', NO_PERSON);
3803
3961
  const member = principalIdSchema.parse(c.req.param('principal'));
3804
3962
  const { tenantId, scopeId, person } = target;
3805
3963
  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 +3975,7 @@ export function createControlPlaneApi(options) {
3817
3975
  // and a team credential cannot show it is that person asking.
3818
3976
  app.post('/tenants/:tenantId/scopes/:scopeId/owner-transfer', async (c) => {
3819
3977
  if (confinedTenant(c.get('principal')) !== null) {
3820
- return c.json({ error: 'forbidden: an owner hand-over is staff-only' }, 403);
3978
+ return refuse(c, 'forbidden', 'forbidden: an owner hand-over is staff-only');
3821
3979
  }
3822
3980
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
3823
3981
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
@@ -3826,10 +3984,10 @@ export function createControlPlaneApi(options) {
3826
3984
  // K-3 first, so a scope of another tenant reads as absent before anything is reached.
3827
3985
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
3828
3986
  if (!scope)
3829
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
3987
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3830
3988
  const vertical = await verticalForScope(c, scope);
3831
3989
  if (!vertical)
3832
- return c.json({ error: await diagnoseUnboundScope(actor, scope) }, 501);
3990
+ return refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope));
3833
3991
  const operationId = ulid();
3834
3992
  // An abandon (`abandon: true`) is audited the same way, every row marked, so the log tells
3835
3993
  // a hand-over closed without finishing from one that finished.
@@ -3862,17 +4020,17 @@ export function createControlPlaneApi(options) {
3862
4020
  const input = configureInstanceBody.parse(await c.req.json());
3863
4021
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
3864
4022
  if (!scope)
3865
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
4023
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3866
4024
  const vertical = await verticalForScope(c, scope);
3867
4025
  if (!vertical) {
3868
- return c.json({ error: await diagnoseUnboundScope(c.get('actor'), scope) }, 501);
4026
+ return refuseStatus(c, 501, await diagnoseUnboundScope(c.get('actor'), scope));
3869
4027
  }
3870
4028
  try {
3871
4029
  await vertical.configureInstance({ tenantId, scopeId, entries: input.entries });
3872
4030
  }
3873
4031
  catch (e) {
3874
4032
  if (e instanceof ControlPlaneError) {
3875
- return c.json({ error: e.message }, failedWith(c, e));
4033
+ return relayRefusal(c, e);
3876
4034
  }
3877
4035
  throw e;
3878
4036
  }
@@ -3929,7 +4087,7 @@ export function createControlPlaneApi(options) {
3929
4087
  message: error,
3930
4088
  }, failure.cause);
3931
4089
  c.set('failureRecorded', true);
3932
- return c.json({ error }, 503);
4090
+ return refuse(c, 'unavailable', error);
3933
4091
  }
3934
4092
  }
3935
4093
  }
@@ -4065,7 +4223,7 @@ export function createControlPlaneApi(options) {
4065
4223
  const body = snapshotScopeBody.parse(await c.req.json().catch(() => ({})));
4066
4224
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
4067
4225
  if (!scope)
4068
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
4226
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4069
4227
  try {
4070
4228
  const { snapId, replay } = await orchestratedSnapshot(c, tenantId, scope, body);
4071
4229
  const record = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, snapId);
@@ -4074,7 +4232,7 @@ export function createControlPlaneApi(options) {
4074
4232
  }
4075
4233
  catch (e) {
4076
4234
  if (e instanceof ControlPlaneError) {
4077
- return c.json({ error: e.message }, failedWith(c, e));
4235
+ return relayRefusal(c, e);
4078
4236
  }
4079
4237
  throw e;
4080
4238
  }
@@ -4120,9 +4278,20 @@ export function createControlPlaneApi(options) {
4120
4278
  */
4121
4279
  const reapScopeStorage = async (c, scope) => {
4122
4280
  const actor = c.get('actor');
4281
+ const dispatch = options.resolveVerticalRef;
4282
+ // #2285: every refusal that can be decided up front comes before anything is destroyed, the
4283
+ // unroute included: a refused reap leaves the scope routed and whole. Then unroute, wipe, delete.
4284
+ if (dispatch)
4285
+ await claimScopeScriptReap({ admin: c.var.admin, actor }, scope.tenantId, scope.id);
4286
+ else
4287
+ await assertNoUnreachableScopeCopies(c.var.admin, actor, scope.tenantId, scope.id);
4288
+ const wipeStores = await platformStoresWipe(c, scope);
4289
+ // Stop routing before the wipe: a call that lands between the wipe and the delete would
4290
+ // recreate the store object, and nothing left could reach it.
4291
+ await unrouteScope(c.var.admin, actor, scope);
4123
4292
  // #2207: the stores first, on every path below: a routed scope's copies are reaped through
4124
4293
  // their scripts, and a store is per scope, not per script.
4125
- await wipePlatformStores(c, scope);
4294
+ await wipeStores();
4126
4295
  // The deployment the scope's vertical resolves to, deleted through; a 501 there is recorded
4127
4296
  // as stranded storage, as `reapScopeScriptCopies` records its own.
4128
4297
  const viaDeployment = async () => {
@@ -4132,13 +4301,11 @@ export function createControlPlaneApi(options) {
4132
4301
  }
4133
4302
  return stranded;
4134
4303
  };
4135
- if (!options.resolveVerticalRef) {
4136
- await assertNoUnreachableScopeCopies(c.var.admin, actor, scope.tenantId, scope.id);
4304
+ if (!dispatch)
4137
4305
  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;
4306
+ const noScript = (await routeOf(c, scope)) === null;
4307
+ const { storageStranded } = await drainScopeScriptCopies({ admin: c.var.admin, actor, resolveRef: dispatch }, scope.tenantId, scope.id);
4308
+ const bySlug = noScript ? await viaDeployment() : false;
4142
4309
  return storageStranded || bySlug;
4143
4310
  };
4144
4311
  app.delete('/tenants/:tenantId/scopes/:scopeId', async (c) => {
@@ -4147,9 +4314,9 @@ export function createControlPlaneApi(options) {
4147
4314
  const actor = c.get('actor');
4148
4315
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
4149
4316
  if (!scope)
4150
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
4317
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4151
4318
  if (!scope.forkedFrom) {
4152
- return c.json({ error: `scope ${scopeId} is not a fork — only snapshots may be deleted` }, 409);
4319
+ return refuse(c, 'conflict', `scope ${scopeId} is not a fork — only snapshots may be deleted`);
4153
4320
  }
4154
4321
  try {
4155
4322
  // Vertical's storage first, then the in-process delete (refusal re-check,
@@ -4162,7 +4329,7 @@ export function createControlPlaneApi(options) {
4162
4329
  }
4163
4330
  catch (e) {
4164
4331
  if (e instanceof ControlPlaneError) {
4165
- return c.json({ error: e.message }, failedWith(c, e));
4332
+ return relayRefusal(c, e);
4166
4333
  }
4167
4334
  throw e;
4168
4335
  }
@@ -4211,12 +4378,12 @@ export function createControlPlaneApi(options) {
4211
4378
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
4212
4379
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
4213
4380
  if (!options.scopeBackups)
4214
- return c.json({ error: 'no backup target configured' }, 501);
4381
+ return refuseStatus(c, 501, 'no backup target configured');
4215
4382
  // Reads the directory, not the store, for the tenant cross-check: the store is keyed
4216
4383
  // by (tenant, scope) but nothing there proves the caller's tenant owns the scope.
4217
4384
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
4218
4385
  if (!scope)
4219
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
4386
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4220
4387
  return c.json(await options.scopeBackups.list({ tenantId, scopeId }));
4221
4388
  });
4222
4389
  // One backup's DUMP — the restore source. Staff-only like the export it came from
@@ -4227,13 +4394,13 @@ export function createControlPlaneApi(options) {
4227
4394
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
4228
4395
  const capturedAt = c.req.param('capturedAt');
4229
4396
  if (!options.scopeBackups)
4230
- return c.json({ error: 'no backup target configured' }, 501);
4397
+ return refuseStatus(c, 501, 'no backup target configured');
4231
4398
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
4232
4399
  if (!scope)
4233
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
4400
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4234
4401
  const dump = await options.scopeBackups.get({ tenantId, scopeId, capturedAt });
4235
4402
  if (!dump)
4236
- return c.json({ error: `no backup for scope ${scopeId} at ${capturedAt}` }, 404);
4403
+ return refuse(c, 'not_found', `no backup for scope ${scopeId} at ${capturedAt}`);
4237
4404
  // Unseal on the way out (#37). The bytes in the store stay sealed — this is the
4238
4405
  // authorized read opening them with the keys the platform holds. A subject shredded
4239
4406
  // since the copy was taken opens to a null payload, which is exactly the erasure
@@ -4250,13 +4417,13 @@ export function createControlPlaneApi(options) {
4250
4417
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
4251
4418
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
4252
4419
  if (!scope)
4253
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
4420
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4254
4421
  try {
4255
4422
  return c.json(await backupScope(c, tenantId, scope), 201);
4256
4423
  }
4257
4424
  catch (e) {
4258
4425
  if (e instanceof ControlPlaneError) {
4259
- return c.json({ error: e.message }, failedWith(c, e));
4426
+ return relayRefusal(c, e);
4260
4427
  }
4261
4428
  throw e;
4262
4429
  }
@@ -4284,7 +4451,7 @@ export function createControlPlaneApi(options) {
4284
4451
  const subjectId = dataSubjectIdSchema.parse(c.req.param('subjectId'));
4285
4452
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
4286
4453
  if (!scope)
4287
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
4454
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4288
4455
  const copies = await listAllScopeScriptCopies(c.var.admin, c.get('actor'), tenantId, scopeId);
4289
4456
  // A move in flight can still restore a copy no inventory read now would name. Its entries
4290
4457
  // settle when it confirms or fails, or, if it died, when the sweep finds its lease run out.
@@ -4293,17 +4460,33 @@ export function createControlPlaneApi(options) {
4293
4460
  }
4294
4461
  const erasureEpoch = await c.var.admin.scopeErasureEpoch(c.get('actor'), tenantId, scopeId);
4295
4462
  const currentRef = await routeOf(c, scope);
4463
+ // The version the deployment runs, read once (#2285): the stores below are judged against it,
4464
+ // and the claim before the key goes requires it, and the serving pointer as read, to still hold.
4465
+ const running = await runningVersionOf(c.var.admin, c.get('actor'), scope);
4296
4466
  // #2207: the stores of the version the deployment runs, resolved before anything is erased. A
4297
4467
  // version that declares none is not asked. One that cannot be resolved while a kind is
4298
4468
  // registered refuses (503): the key must not be destroyed while a store may hold the subject.
4299
- const reach = await platformStoresOf(c, scope);
4469
+ const reach = await platformStoresOf(c, scope, running);
4470
+ /**
4471
+ * L19: a bind, promote, rollback or serving re-upload that lands while the stores are erased
4472
+ * would run a version whose stores this erasure did not reach. Both arms take one atomic claim
4473
+ * on this fence before the key goes, and the claim moves the erasure epoch every bind compares,
4474
+ * so the bind either lands first and the claim refuses (412, the key kept), or it is refused.
4475
+ */
4476
+ const fence = {
4477
+ versionId: scope.verticalVersionId,
4478
+ servingRef: scope.servingRef ?? null,
4479
+ epoch: erasureEpoch,
4480
+ copyCount: copies.length,
4481
+ ...(running.servingPointer !== undefined ? { servingPointer: running.servingPointer } : {}),
4482
+ };
4300
4483
  // Not asked is `undefined`, not `[]` (#2242): with no platform path the host's own stores
4301
4484
  // (the pure adapter's co-located runners) are erased in its shred or its finalize.
4302
4485
  const eraseStores = async () => reach ? reach.caller.eraseSubject({ tenantId, scopeId, vertical: reach.vertical, subjectId, stores: reach.stores }) : undefined;
4303
4486
  /** The host's own shred, with the platform's store erasure first: a store that errors keeps the key. */
4304
4487
  const shredHere = async () => {
4305
4488
  const storeErasures = await eraseStores();
4306
- const receipt = await c.var.admin.shredSubject(c.get('actor'), tenantId, scopeId, subjectId);
4489
+ const receipt = await c.var.admin.shredSubject(c.get('actor'), tenantId, scopeId, subjectId, fence).catch(relayHostRefusal);
4307
4490
  return { ...receipt, storeErasures: [...receipt.storeErasures, ...(storeErasures ?? [])] };
4308
4491
  };
4309
4492
  if (!options.resolveVerticalRef) {
@@ -4332,10 +4515,7 @@ export function createControlPlaneApi(options) {
4332
4515
  if (bySlug)
4333
4516
  redactions.push(await bySlug.redactSubject(scopeId, subjectId));
4334
4517
  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));
4518
+ return c.json(await c.var.admin.finalizeSubjectShred(c.get('actor'), tenantId, scopeId, subjectId, redactions, fence, storeErasures).catch(relayHostRefusal));
4339
4519
  });
4340
4520
  // -- directory backups (#40) -----------------------------------------------
4341
4521
  // The platform's own disaster recovery, on the same store posture as the scope
@@ -4350,7 +4530,7 @@ export function createControlPlaneApi(options) {
4350
4530
  // question an unrehearsed backup story never has a way to ask.
4351
4531
  app.get('/directory/backups', async (c) => {
4352
4532
  if (!options.directoryBackups)
4353
- return c.json({ error: 'no directory backup target configured' }, 501);
4533
+ return refuseStatus(c, 501, 'no directory backup target configured');
4354
4534
  return c.json(await options.directoryBackups.list());
4355
4535
  });
4356
4536
  // Take one NOW, cadence ignored — the pre-migration checkpoint an operator takes by
@@ -4358,7 +4538,7 @@ export function createControlPlaneApi(options) {
4358
4538
  // copy without waiting a day for the cron.
4359
4539
  app.post('/directory/backups', async (c) => {
4360
4540
  if (!options.directoryBackups)
4361
- return c.json({ error: 'no directory backup target configured' }, 501);
4541
+ return refuseStatus(c, 501, 'no directory backup target configured');
4362
4542
  const result = await backupDirectoryIfDue({
4363
4543
  admin: c.var.admin,
4364
4544
  store: options.directoryBackups,
@@ -4373,11 +4553,11 @@ export function createControlPlaneApi(options) {
4373
4553
  // and audited by `exportDirectory` underneath.
4374
4554
  app.get('/directory/backups/:capturedAt', async (c) => {
4375
4555
  if (!options.directoryBackups)
4376
- return c.json({ error: 'no directory backup target configured' }, 501);
4556
+ return refuseStatus(c, 501, 'no directory backup target configured');
4377
4557
  const capturedAt = c.req.param('capturedAt');
4378
4558
  const dump = await options.directoryBackups.get({ capturedAt });
4379
4559
  if (!dump)
4380
- return c.json({ error: `no directory backup at ${capturedAt}` }, 404);
4560
+ return refuse(c, 'not_found', `no directory backup at ${capturedAt}`);
4381
4561
  return c.json(dump);
4382
4562
  });
4383
4563
  // Break-glass: REPLACE the directory with a stored copy.
@@ -4391,18 +4571,16 @@ export function createControlPlaneApi(options) {
4391
4571
  // with nothing in it — needs no such ceremony.
4392
4572
  app.post('/directory/restore', async (c) => {
4393
4573
  if (!options.directoryBackups)
4394
- return c.json({ error: 'no directory backup target configured' }, 501);
4574
+ return refuseStatus(c, 501, 'no directory backup target configured');
4395
4575
  const body = restoreDirectoryBody.parse(await c.req.json().catch(() => ({})));
4396
4576
  const dump = await options.directoryBackups.get({ capturedAt: body.capturedAt });
4397
4577
  if (!dump)
4398
- return c.json({ error: `no directory backup at ${body.capturedAt}` }, 404);
4578
+ return refuse(c, 'not_found', `no directory backup at ${body.capturedAt}`);
4399
4579
  const actor = c.get('actor');
4400
4580
  const live = await c.var.admin.listTenants(actor, { limit: 1 });
4401
4581
  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);
4582
+ return refuse(c, 'conflict', 'the directory is not empty — a restore REPLACES it, so anything created ' +
4583
+ 'since this copy was taken would be lost; pass overwrite=true to confirm');
4406
4584
  }
4407
4585
  await c.var.admin.restoreDirectory(actor, dump);
4408
4586
  return c.json({ capturedAt: dump.capturedAt, tables: dump.tables.length });
@@ -4454,7 +4632,7 @@ export function createControlPlaneApi(options) {
4454
4632
  };
4455
4633
  app.post('/previews/repair-serving-pins', async (c) => {
4456
4634
  if (!options.resolveVerticalVersion || !options.resolveVerticalRef) {
4457
- return c.json({ error: 'repairing serving pins needs dispatch resolution for both ends' }, 501);
4635
+ return refuseStatus(c, 501, 'repairing serving pins needs dispatch resolution for both ends');
4458
4636
  }
4459
4637
  const { cursor, limit, dryRun } = await readJsonBody(c, repairServingPinsBody);
4460
4638
  const actor = c.get('actor');
@@ -4625,13 +4803,13 @@ export function createControlPlaneApi(options) {
4625
4803
  const actor = c.get('actor');
4626
4804
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
4627
4805
  if (!scope)
4628
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
4806
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4629
4807
  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);
4808
+ return refuse(c, 'conflict', 'clear-copy-mark refused: the directory says this scope is a copy (a preview or a fork)');
4631
4809
  }
4632
4810
  const vertical = await verticalForScope(c, scope);
4633
4811
  if (!vertical)
4634
- return c.json({ error: await diagnoseUnboundScope(actor, scope) }, 501);
4812
+ return refuseStatus(c, 501, await diagnoseUnboundScope(actor, scope));
4635
4813
  const { cleared } = await vertical.clearCopyMark(scopeId, { kind: scope.kind, forkedFrom: scope.forkedFrom });
4636
4814
  await c.var.admin.recordCopyMark(actor, { action: 'clear', tenantId, scopeId, outcome: cleared ? 'cleared' : 'absent' });
4637
4815
  return c.json({ cleared });
@@ -4657,16 +4835,14 @@ export function createControlPlaneApi(options) {
4657
4835
  const actor = c.get('actor');
4658
4836
  const parsed = redrainEventsInput.safeParse(await c.req.json().catch(() => ({})));
4659
4837
  if (!parsed.success) {
4660
- return c.json({ error: 'body must be { drainedBefore: <ISO 8601 instant> } — the end of the window to reopen' }, 400);
4838
+ return refuse(c, 'validation_failed', 'body must be { drainedBefore: <ISO 8601 instant> } — the end of the window to reopen');
4661
4839
  }
4662
4840
  // Refused at the door rather than left to the adapters: an instant in the future
4663
4841
  // reopens rows the drain shipped AFTER the rebuild, which is exactly the double-write
4664
4842
  // the required instant exists to prevent. Host code, so it may read the real clock.
4665
4843
  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);
4844
+ return refuse(c, 'validation_failed', `drainedBefore ${parsed.data.drainedBefore} is in the future — it would reopen rows ` +
4845
+ 'already shipped to the rebuilt table. Pass the instant the old table stopped receiving.');
4670
4846
  }
4671
4847
  // The input schema also carries `countOnly` (#1545), and this route does not honour it:
4672
4848
  // its answer (`redrained`, `more`) is a statement that rows moved. Ignoring the field
@@ -4675,11 +4851,11 @@ export function createControlPlaneApi(options) {
4675
4851
  // caller is told where counting lives. Refused whatever its value: on this route the
4676
4852
  // field has no meaning at all, and "accepted when false" is a rule nobody can rely on.
4677
4853
  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);
4854
+ return refuse(c, 'validation_failed', 'countOnly is not honoured here — POST the same body to …/redrain-count for a read-only count');
4679
4855
  }
4680
4856
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
4681
4857
  if (!scope)
4682
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
4858
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4683
4859
  // The instant alone, never `parsed.data`, so the field cannot reach the adapter from
4684
4860
  // this door even if the refusal above is ever relaxed.
4685
4861
  const redrained = await c.var.admin.redrainEvents(actor, tenantId, scopeId, {
@@ -4710,26 +4886,72 @@ export function createControlPlaneApi(options) {
4710
4886
  const actor = c.get('actor');
4711
4887
  const parsed = redrainEventsInput.safeParse(await c.req.json().catch(() => ({})));
4712
4888
  if (!parsed.success) {
4713
- return c.json({ error: 'body must be { drainedBefore: <ISO 8601 instant> } — the window to count' }, 400);
4889
+ return refuse(c, 'validation_failed', 'body must be { drainedBefore: <ISO 8601 instant> } — the window to count');
4714
4890
  }
4715
4891
  // The same future refusal as the reopen. A count of a window that has not closed yet
4716
4892
  // is not dangerous, it is meaningless — and a dry run whose instant this door accepts
4717
4893
  // and the real run rejects would be the worst kind of rehearsal.
4718
4894
  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);
4895
+ return refuse(c, 'validation_failed', `drainedBefore ${parsed.data.drainedBefore} is in the future — it would count rows ` +
4896
+ 'already shipped to the rebuilt table. Pass the instant the old table stopped receiving.');
4723
4897
  }
4724
4898
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
4725
4899
  if (!scope)
4726
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
4900
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4727
4901
  const redrainable = await c.var.admin.redrainEvents(actor, tenantId, scopeId, {
4728
4902
  drainedBefore: parsed.data.drainedBefore,
4729
4903
  countOnly: true,
4730
4904
  });
4731
4905
  return c.json({ redrainable, drainedBefore: parsed.data.drainedBefore });
4732
4906
  });
4907
+ // #1334 — one tenant's events from the Tier-2 lake, through the gateway that scopes them
4908
+ // (`lake-gateway.ts`): the tenant is this path segment and nothing else, the filters are a
4909
+ // closed `.strict()` list with no tenant in it, and a row of any other tenant fails the
4910
+ // read whole. Envelope only — no payload until erasure reaches the lake.
4911
+ //
4912
+ // Staff only, by default-deny: on neither BUILDER_ROUTES nor TENANT_ROUTES. A tenant's
4913
+ // own credential reaching this is a separate decision, with a dashboard call site and a
4914
+ // member permission in front of it.
4915
+ //
4916
+ // K-24: the access row is written AFTER the engine answers and BEFORE anything is
4917
+ // returned, and a failed write fails the request — rows whose disclosure went unrecorded
4918
+ // are what the access log exists to prevent. A refused or failed query discloses nothing
4919
+ // and leaves no row.
4920
+ app.get('/tenants/:tenantId/lake/events', async (c) => {
4921
+ const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
4922
+ if (!options.lakeQuery) {
4923
+ return refuseStatus(c, 501, 'the Tier-2 lake is not queryable on this control plane');
4924
+ }
4925
+ const parsed = lakeEventsInput.safeParse(c.req.query());
4926
+ if (!parsed.success) {
4927
+ // `errors` is the declared member (#113); `issues`, zod's own shape, is what this route
4928
+ // answered before it and stays for the clients that read it. Carried rather than built by
4929
+ // `coded`, which would answer a field list with its canonical sentence instead of this one.
4930
+ return refuse(c, 'validation_failed', 'invalid lake query', {
4931
+ errors: validationIssuesFrom(parsed.error),
4932
+ issues: parsed.error.issues.map((i) => ({ path: i.path, message: i.message })),
4933
+ });
4934
+ }
4935
+ let page;
4936
+ try {
4937
+ page = await queryLakeEvents(options.lakeQuery, tenantId, parsed.data);
4938
+ }
4939
+ catch (err) {
4940
+ if (err instanceof LakeQueryError)
4941
+ return refuseStatus(c, 502, err.message);
4942
+ throw err;
4943
+ }
4944
+ // The filters, not the page: the same projection every other read logs.
4945
+ const { cursor: _cursor, limit: _limit, ...filters } = parsed.data;
4946
+ await c.var.admin.recordDelegatedRead(c.get('actor'), {
4947
+ method: 'lakeEvents',
4948
+ tenantId,
4949
+ scopeId: parsed.data.scopeId ?? null,
4950
+ params: filters,
4951
+ resultCount: page.events.length,
4952
+ });
4953
+ return c.json(page);
4954
+ });
4733
4955
  app.post('/tenants/:tenantId/scopes/:scopeId/reap', async (c) => {
4734
4956
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
4735
4957
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
@@ -4739,9 +4961,9 @@ export function createControlPlaneApi(options) {
4739
4961
  const { backup: wantsBackup } = await readJsonBody(c, reapScopeBody);
4740
4962
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
4741
4963
  if (!scope)
4742
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
4964
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4743
4965
  if (scope.status !== 'archived') {
4744
- return c.json({ error: `scope ${scopeId} is ${scope.status}, not archived — only an archived scope may be reaped` }, 409);
4966
+ return refuse(c, 'conflict', `scope ${scopeId} is ${scope.status}, not archived — only an archived scope may be reaped`);
4745
4967
  }
4746
4968
  // Refuse a scope that still resolves a hostname BEFORE any delegation — the vertical's
4747
4969
  // `deleteScope` below wipes the hosted DO's bytes, so this must gate ahead of it, not
@@ -4750,10 +4972,8 @@ export function createControlPlaneApi(options) {
4750
4972
  // the same guard because releasing every name is the point there.)
4751
4973
  const boundNames = await c.var.admin.listHostnames(actor, { scopeId, limit: 1 });
4752
4974
  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);
4975
+ return refuse(c, 'conflict', `scope ${scopeId} still resolves hostname '${boundNames[0].hostname}' — ` +
4976
+ `unbind it before reaping (reap wipes storage and cannot be undone)`);
4757
4977
  }
4758
4978
  // The recoverable copy, BEFORE any byte is wiped (#493). Ordering is the whole
4759
4979
  // guarantee: `backupScope` has to have resolved — durably stored — before
@@ -4772,13 +4992,10 @@ export function createControlPlaneApi(options) {
4772
4992
  }
4773
4993
  catch (e) {
4774
4994
  if (e instanceof ControlPlaneError) {
4775
- return c.json({ error: e.message }, failedWith(c, e));
4995
+ return relayRefusal(c, e);
4776
4996
  }
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);
4997
+ return refuseStatus(c, 502, 'backup failed — the scope was NOT reaped and its data is intact; ' +
4998
+ 'retry, or reap with backup=false to accept an unrecoverable wipe', { detail: e instanceof Error ? e.message : String(e) });
4782
4999
  }
4783
5000
  }
4784
5001
  try {
@@ -4793,7 +5010,7 @@ export function createControlPlaneApi(options) {
4793
5010
  }
4794
5011
  catch (e) {
4795
5012
  if (e instanceof ControlPlaneError) {
4796
- return c.json({ error: e.message }, failedWith(c, e));
5013
+ return relayRefusal(c, e);
4797
5014
  }
4798
5015
  throw e;
4799
5016
  }
@@ -4810,15 +5027,13 @@ export function createControlPlaneApi(options) {
4810
5027
  const actor = c.get('actor');
4811
5028
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
4812
5029
  if (!scope)
4813
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
5030
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4814
5031
  // Residency (K-7/K-32): jurisdiction pins EXECUTION, not just storage. A pull
4815
5032
  // lands the data on a machine outside the platform's control, so anything
4816
5033
  // pinned tighter than `global` is refused until a compliant path exists.
4817
5034
  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);
5035
+ return refuse(c, 'forbidden', `scope ${scopeId} is pinned to '${scope.jurisdiction}' — a local pull would ` +
5036
+ `move its data outside that jurisdiction; refused (K-32, preview-and-snapshots.md §6)`);
4822
5037
  }
4823
5038
  const full = c.req.query('full') === 'true';
4824
5039
  try {
@@ -4846,7 +5061,7 @@ export function createControlPlaneApi(options) {
4846
5061
  }
4847
5062
  catch (e) {
4848
5063
  if (e instanceof ControlPlaneError) {
4849
- return c.json({ error: e.message }, failedWith(c, e));
5064
+ return relayRefusal(c, e);
4850
5065
  }
4851
5066
  throw e;
4852
5067
  }
@@ -4873,7 +5088,7 @@ export function createControlPlaneApi(options) {
4873
5088
  const actor = c.get('actor');
4874
5089
  const tenant = await c.var.admin.getTenant(actor, tenantId);
4875
5090
  if (!tenant)
4876
- return c.json({ error: `unknown tenant: ${tenantId}` }, 404);
5091
+ return refuse(c, 'not_found', `unknown tenant: ${tenantId}`);
4877
5092
  // Every scope, tombstones included: an archived or reaped scope is part of the
4878
5093
  // tenant's history, and an export that quietly dropped them would misrepresent what
4879
5094
  // the tenant was. Their DATA is a different question, handled below.
@@ -4885,12 +5100,10 @@ export function createControlPlaneApi(options) {
4885
5100
  // partial is the failure mode worth avoiding.
4886
5101
  const pinned = scopes.filter((s) => s.jurisdiction !== 'global');
4887
5102
  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);
5103
+ return refuse(c, 'forbidden', `tenant ${tenantId} has ${pinned.length} scope(s) pinned to a jurisdiction ` +
5104
+ `(${[...new Set(pinned.map((s) => s.jurisdiction))].join(', ')}) — an export would ` +
5105
+ `move that data out of the region it was promised; refused (K-32). Export the ` +
5106
+ `global scopes individually, or wait for a per-jurisdiction path.`);
4894
5107
  }
4895
5108
  const full = c.req.query('full') === 'true';
4896
5109
  try {
@@ -4969,7 +5182,7 @@ export function createControlPlaneApi(options) {
4969
5182
  }
4970
5183
  catch (e) {
4971
5184
  if (e instanceof ControlPlaneError) {
4972
- return c.json({ error: e.message }, failedWith(c, e));
5185
+ return relayRefusal(c, e);
4973
5186
  }
4974
5187
  throw e;
4975
5188
  }
@@ -4991,7 +5204,7 @@ export function createControlPlaneApi(options) {
4991
5204
  const actor = c.get('actor');
4992
5205
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
4993
5206
  if (!scope)
4994
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
5207
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4995
5208
  const raw = await c.req.json();
4996
5209
  const dump = scopeDump.parse(raw);
4997
5210
  // #1869: where the uploaded file says it came from, when the caller had to set the dump's
@@ -5001,7 +5214,7 @@ export function createControlPlaneApi(options) {
5001
5214
  // A backup with no tables is not a scope dump — name that plainly rather than letting
5002
5215
  // the empty replay reach the checker and surface as a bare `internal error` (#321).
5003
5216
  if (dump.tables.length === 0) {
5004
- return c.json({ error: 'restore refused: the backup has no tables — not a scope dump' }, 422);
5217
+ return refuseStatus(c, 422, 'restore refused: the backup has no tables — not a scope dump');
5005
5218
  }
5006
5219
  // #2207: the dump's stores against the destination's declaration, before anything is written.
5007
5220
  if (options.platformScopeStores) {
@@ -5010,7 +5223,7 @@ export function createControlPlaneApi(options) {
5010
5223
  }
5011
5224
  catch (e) {
5012
5225
  if (e instanceof ControlPlaneError)
5013
- return c.json({ error: e.message }, failedWith(c, e));
5226
+ return relayRefusal(c, e);
5014
5227
  throw e;
5015
5228
  }
5016
5229
  }
@@ -5076,7 +5289,7 @@ export function createControlPlaneApi(options) {
5076
5289
  // reconcile); a clear that fails here must not hide the restore's own error.
5077
5290
  await c.var.admin.markScopeProvisioned(actor, tenantId, scopeId, null).catch(() => undefined);
5078
5291
  if (e instanceof ControlPlaneError) {
5079
- return c.json({ error: e.message }, failedWith(c, e));
5292
+ return relayRefusal(c, e);
5080
5293
  }
5081
5294
  // A restore throw is driven by the caller-supplied dump (a shape the target cannot
5082
5295
  // load, a DDL the engine rejects), so DISCLOSE it as an actionable 422 rather than
@@ -5084,7 +5297,7 @@ export function createControlPlaneApi(options) {
5084
5297
  // secondary obs #2). The route is staff/owner-gated and the detail is about the
5085
5298
  // dump the caller sent, not another tenant's state.
5086
5299
  const detail = e instanceof Error ? e.message : String(e);
5087
- return c.json({ error: 'restore failed — the backup could not be loaded', detail }, 422);
5300
+ return refuseStatus(c, 422, 'restore failed — the backup could not be loaded', { detail });
5088
5301
  }
5089
5302
  });
5090
5303
  // #1236: when this scope's migrations actually ran — the schema-change history
@@ -5097,7 +5310,7 @@ export function createControlPlaneApi(options) {
5097
5310
  const actor = c.get('actor');
5098
5311
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
5099
5312
  if (!scope)
5100
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
5313
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5101
5314
  const vertical = await verticalForScope(c, scope);
5102
5315
  return c.json(vertical
5103
5316
  ? await vertical.appliedMigrations(scopeId)
@@ -5113,7 +5326,7 @@ export function createControlPlaneApi(options) {
5113
5326
  const actor = c.get('actor');
5114
5327
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
5115
5328
  if (!scope)
5116
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
5329
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5117
5330
  const vertical = await verticalForScope(c, scope);
5118
5331
  return c.json(vertical
5119
5332
  ? await vertical.migrationBookmarks(scopeId)
@@ -5133,7 +5346,7 @@ export function createControlPlaneApi(options) {
5133
5346
  const actor = c.get('actor');
5134
5347
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
5135
5348
  if (!scope)
5136
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
5349
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5137
5350
  try {
5138
5351
  const vertical = await verticalForScope(c, scope);
5139
5352
  const result = await c.var.admin.rewindScope(actor, tenantId, scopeId, bookmark, {
@@ -5156,7 +5369,7 @@ export function createControlPlaneApi(options) {
5156
5369
  }
5157
5370
  catch (e) {
5158
5371
  if (e instanceof ControlPlaneError) {
5159
- return c.json({ error: e.message }, failedWith(c, e));
5372
+ return relayRefusal(c, e);
5160
5373
  }
5161
5374
  throw e;
5162
5375
  }
@@ -5176,7 +5389,7 @@ export function createControlPlaneApi(options) {
5176
5389
  }
5177
5390
  catch (e) {
5178
5391
  if (e instanceof ControlPlaneError) {
5179
- return c.json(exportBreakBody(e), failedWith(c, e));
5392
+ return relayRefusal(c, e, exportBreaksOf(e));
5180
5393
  }
5181
5394
  throw e;
5182
5395
  }
@@ -5194,13 +5407,13 @@ export function createControlPlaneApi(options) {
5194
5407
  if (adoptPin) {
5195
5408
  const v = await verticalOf(actor, slug);
5196
5409
  if (!v || v.ownerTenant !== adoptPin)
5197
- return c.json({ error: 'forbidden' }, 403);
5410
+ return refuse(c, 'forbidden', 'forbidden');
5198
5411
  }
5199
5412
  const v = await verticalOf(actor, slug);
5200
5413
  if (!v)
5201
- return c.json({ error: `unknown vertical '${slug}'` }, 404);
5414
+ return refuse(c, 'not_found', `unknown vertical '${slug}'`);
5202
5415
  if (v.ownerTenant === null) {
5203
- return c.json({ error: 'adopt-serving is a private-vertical operation' }, 409);
5416
+ return refuse(c, 'conflict', 'adopt-serving is a private-vertical operation');
5204
5417
  }
5205
5418
  const owned = (await c.var.admin.listScopes(actor, { tenantId: v.ownerTenant, vertical: slug })).filter((s) => !s.forkedFrom && s.kind !== 'preview' && s.status === 'active');
5206
5419
  const { acknowledge } = await readJsonBody(c, adoptVerticalServingBody);
@@ -5214,7 +5427,7 @@ export function createControlPlaneApi(options) {
5214
5427
  }
5215
5428
  catch (e) {
5216
5429
  if (e instanceof ControlPlaneError) {
5217
- return c.json({ ...exportBreakBody(e), adopted, alreadyAdopted }, failedWith(c, e));
5430
+ return relayRefusal(c, e, { ...exportBreaksOf(e), adopted, alreadyAdopted });
5218
5431
  }
5219
5432
  throw e;
5220
5433
  }
@@ -5234,7 +5447,7 @@ export function createControlPlaneApi(options) {
5234
5447
  // another tenant's private vertical, which a confined caller must not even learn exists
5235
5448
  // (404, as `GET /verticals/:slug` answers it).
5236
5449
  if (await notReadable(c.get('principal'), vertical)) {
5237
- return c.json({ error: `unknown vertical: ${vertical}` }, 404);
5450
+ return refuse(c, 'not_found', `unknown vertical: ${vertical}`);
5238
5451
  }
5239
5452
  try {
5240
5453
  const r = await rebindScopeOntoVertical(c, tenantId, scopeId, vertical, {
@@ -5246,7 +5459,7 @@ export function createControlPlaneApi(options) {
5246
5459
  }
5247
5460
  catch (e) {
5248
5461
  if (e instanceof ControlPlaneError) {
5249
- return c.json(exportBreakBody(e), failedWith(c, e));
5462
+ return relayRefusal(c, e, exportBreaksOf(e));
5250
5463
  }
5251
5464
  throw e;
5252
5465
  }
@@ -5317,7 +5530,7 @@ export function createControlPlaneApi(options) {
5317
5530
  const { script } = keptScriptQuery.parse(c.req.query());
5318
5531
  const scope = await c.var.admin.getScopeRecord(c.get('actor'), tenantId, scopeId);
5319
5532
  if (!scope)
5320
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
5533
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5321
5534
  const kept = await (await keptHolder(script)).keptCopy(scopeId);
5322
5535
  return c.json({ scopeId, script, kept, routesTo: await routeOf(c, scope) });
5323
5536
  });
@@ -5336,17 +5549,15 @@ export function createControlPlaneApi(options) {
5336
5549
  const actor = c.get('actor');
5337
5550
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
5338
5551
  if (!scope)
5339
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
5552
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5340
5553
  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);
5554
+ return refuse(c, 'forbidden', `scope ${scopeId} is pinned to '${scope.jurisdiction}' — a local pull would ` +
5555
+ `move its data outside that jurisdiction; refused (K-32, preview-and-snapshots.md §6)`);
5345
5556
  }
5346
5557
  const holder = await keptHolder(script);
5347
5558
  const kept = await holder.keptCopy(scopeId);
5348
5559
  if (!kept)
5349
- return c.json({ error: `scope ${scopeId} has no kept copy in '${script}'` }, 409);
5560
+ return refuse(c, 'conflict', `scope ${scopeId} has no kept copy in '${script}'`);
5350
5561
  const dump = await c.var.admin.exportScope(actor, tenantId, scopeId);
5351
5562
  const tables = await holder.exportScope(scopeId);
5352
5563
  if (full === 'true')
@@ -5361,20 +5572,18 @@ export function createControlPlaneApi(options) {
5361
5572
  const actor = c.get('actor');
5362
5573
  const scope = await c.var.admin.getScopeRecord(actor, tenantId, scopeId);
5363
5574
  if (!scope)
5364
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
5575
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5365
5576
  const holder = await keptHolder(body.script);
5366
5577
  const kept = await holder.keptCopy(scopeId);
5367
5578
  if (!kept)
5368
- return c.json({ error: `scope ${scopeId} has no kept copy in '${body.script}'` }, 409);
5579
+ return refuse(c, 'conflict', `scope ${scopeId} has no kept copy in '${body.script}'`);
5369
5580
  const route = await routeOf(c, scope);
5370
5581
  // `release` is for a kept copy the scope routes to (a rollback bound it after a carry kept
5371
5582
  // it); the other two act on a kept copy the scope has left, against the store it runs on.
5372
5583
  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);
5584
+ return refuse(c, 'conflict', body.action === 'release'
5585
+ ? `scope ${scopeId} routes to '${route ?? '(nothing)'}', not to the kept copy in '${body.script}'; release is for a kept copy that is live`
5586
+ : `scope ${scopeId} routes to '${route ?? '(nothing)'}', so there is no live copy to resolve against`);
5378
5587
  }
5379
5588
  const at = new Date().toISOString();
5380
5589
  const revisionOf = async (client) => {
@@ -5405,7 +5614,7 @@ export function createControlPlaneApi(options) {
5405
5614
  if (body.action === 'release') {
5406
5615
  const out = await holder.releaseKeptCopy({ scopeId, revision: revisionBefore, ...markCopy, ...stampFence });
5407
5616
  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);
5617
+ 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
5618
  }
5410
5619
  await record('release', revisionBefore);
5411
5620
  return c.json({ scopeId, script: body.script, action: 'release', revisionBefore });
@@ -5413,7 +5622,7 @@ export function createControlPlaneApi(options) {
5413
5622
  if (body.action === 'discard') {
5414
5623
  const out = await holder.discardKeptCopy({ scopeId, revision: revisionBefore, carriedTo: route, at, ...markCopy, ...stampFence });
5415
5624
  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);
5625
+ 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
5626
  }
5418
5627
  const revisionAfter = await revisionOf(holder);
5419
5628
  await record('discard', revisionAfter);
@@ -5421,10 +5630,8 @@ export function createControlPlaneApi(options) {
5421
5630
  }
5422
5631
  // restore-forward: the acknowledgement names what is replaced, as the marker says it.
5423
5632
  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);
5633
+ return refuse(c, 'conflict', `a restore forward replaces what the live store took since ${kept.keptAt}; acknowledge exactly that ` +
5634
+ `(acknowledge.replacesLiveWritesSince: '${kept.keptAt}')`);
5428
5635
  }
5429
5636
  // Fenced on the binding through the whole resolution (Codex #2008 r8): the kept copy goes only
5430
5637
  // once its data is in the store the scope still routes to. Every refusal below leaves it kept.
@@ -5436,21 +5643,19 @@ export function createControlPlaneApi(options) {
5436
5643
  (now.verticalVersionId ?? null) === (scope.verticalVersionId ?? null) &&
5437
5644
  (now.servingRef ?? null) === (scope.servingRef ?? null));
5438
5645
  };
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);
5646
+ const moved = () => refuse(c, 'conflict', `scope ${scopeId} was re-pointed while the kept copy was being restored forward, so nothing was resolved ` +
5647
+ `and the kept copy in '${body.script}' stays kept; read it again and resolve it again`);
5443
5648
  // The live store as read now, and nothing else, is what gets replaced. The binding read again
5444
5649
  // after the marker ties the marker to the live store: a move that landed before this read
5445
5650
  // (its wipe minting a tombstone here) changed the binding, and is refused.
5446
5651
  const liveMarker = await live.loadMarker(scopeId);
5447
5652
  if (liveMarker === 'unfenced') {
5448
- return c.json({ error: `the deployment serving scope ${scopeId} ('${liveScript}') predates the fence (#1722); re-push it, then resolve` }, 409);
5653
+ return refuse(c, 'conflict', `the deployment serving scope ${scopeId} ('${liveScript}') predates the fence (#1722); re-push it, then resolve`);
5449
5654
  }
5450
5655
  if (!(await bindingHeld()))
5451
5656
  return moved();
5452
5657
  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);
5658
+ return refuse(c, 'conflict', `the store scope ${scopeId} routes to in '${liveScript}' is not a live store (wiped or kept); nothing was resolved`);
5454
5659
  }
5455
5660
  const dump = await retryTransient(() => holder.exportScope(scopeId));
5456
5661
  // #2185: before the load as well as after it, so a throw once the kept copy has landed (the
@@ -5489,10 +5694,8 @@ export function createControlPlaneApi(options) {
5489
5694
  ...stampFence,
5490
5695
  });
5491
5696
  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);
5697
+ return refuse(c, 'conflict', `restored forward into '${route}', but the kept copy in '${body.script}' changed since it was read, so it ` +
5698
+ `stays kept: read it again and resolve it again`);
5496
5699
  }
5497
5700
  return c.json({ scopeId, script: body.script, action: 'restore-forward', liveScript, revisionBefore, revisionAfter });
5498
5701
  });
@@ -5550,7 +5753,7 @@ export function createControlPlaneApi(options) {
5550
5753
  };
5551
5754
  if (snapshot) {
5552
5755
  if (!scope) {
5553
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
5756
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5554
5757
  }
5555
5758
  const vertical = await verticalForScope(c, scope);
5556
5759
  if (vertical) {
@@ -5625,13 +5828,13 @@ export function createControlPlaneApi(options) {
5625
5828
  // uniform whether or not anything is deployed. Existing scopes keep serving.
5626
5829
  const registered = (await c.var.admin.listVerticals(c.get('actor'))).find((v) => v.slug === slug);
5627
5830
  if (registered?.installsBlocked) {
5628
- return c.json({ error: `new installs of vertical '${slug}' are blocked` }, 403);
5831
+ return refuse(c, 'forbidden', `new installs of vertical '${slug}' are blocked`);
5629
5832
  }
5630
5833
  // Static binding first (milestone-one shape), then the dispatch resolver for a
5631
5834
  // pushed vertical — the provisioning mirror of the router's verticalFor.
5632
5835
  const vertical = options.verticals?.[slug] ?? (await options.resolveVertical?.(slug, c.get('actor')));
5633
5836
  if (!vertical) {
5634
- return c.json({ error: `no deployment is bound for vertical '${slug}'` }, 501);
5837
+ return refuseStatus(c, 501, `no deployment is bound for vertical '${slug}'`);
5635
5838
  }
5636
5839
  const input = provisionInstanceBody.parse(await c.req.json());
5637
5840
  // #310: the platform is the authoritative source of the tenant's entitlements — it
@@ -5750,7 +5953,7 @@ export function createControlPlaneApi(options) {
5750
5953
  message: e.message,
5751
5954
  }, e);
5752
5955
  }
5753
- return c.json({ error: e.message }, failedWith(c, e));
5956
+ return relayRefusal(c, e);
5754
5957
  }
5755
5958
  throw e;
5756
5959
  }
@@ -5979,7 +6182,7 @@ export function createControlPlaneApi(options) {
5979
6182
  input.slug = effectiveSlug(p, input.slug);
5980
6183
  const owner = await ownerOf(p.actor, input.slug);
5981
6184
  if (owner !== undefined && owner !== p.tenantId)
5982
- return c.json({ error: 'forbidden' }, 403);
6185
+ return refuse(c, 'forbidden', 'forbidden');
5983
6186
  input.ownerTenant = p.tenantId;
5984
6187
  }
5985
6188
  await c.var.admin.registerVertical(c.get('actor'), input);
@@ -5994,7 +6197,7 @@ export function createControlPlaneApi(options) {
5994
6197
  // A builder reading a vertical it does not own gets 404 — indistinguishable from
5995
6198
  // absent, the same fail-closed reflex K-3 uses for a cross-tenant scope.
5996
6199
  if (await notReadable(p, slug)) {
5997
- return c.json({ error: 'not found' }, 404);
6200
+ return refuse(c, 'not_found', 'not found');
5998
6201
  }
5999
6202
  const page = pageParams(c);
6000
6203
  const entries = await c.var.admin.listVersions(c.get('actor'), slug, page);
@@ -6006,12 +6209,12 @@ export function createControlPlaneApi(options) {
6006
6209
  // the same fail-closed cross-check `(tenantId, scopeId)` makes (K-3) — a mismatch is
6007
6210
  // a client bug, not a silent publish under the wrong vertical.
6008
6211
  if (input.verticalSlug !== c.req.param('slug')) {
6009
- return c.json({ error: 'verticalSlug does not match the path' }, 400);
6212
+ return refuse(c, 'validation_failed', 'verticalSlug does not match the path');
6010
6213
  }
6011
6214
  const p = c.get('principal');
6012
6215
  const slug = await resolveVerticalId(c, input.verticalSlug);
6013
6216
  if (await notOwned(p, slug)) {
6014
- return c.json({ error: 'forbidden' }, 403);
6217
+ return refuse(c, 'forbidden', 'forbidden');
6015
6218
  }
6016
6219
  input.verticalSlug = slug;
6017
6220
  // `manifestJson` is a string the schema cannot look inside, and every reader parses it
@@ -6028,19 +6231,19 @@ export function createControlPlaneApi(options) {
6028
6231
  raw = JSON.parse(input.manifestJson);
6029
6232
  }
6030
6233
  catch {
6031
- return c.json({ error: 'manifestJson is not valid JSON' }, 400);
6234
+ return refuse(c, 'validation_failed', 'manifestJson is not valid JSON');
6032
6235
  }
6033
- const refuse = (issue, under = []) => c.json({ error: `manifestJson is not a deploy manifest: ${[...under, ...issue.path].join('.') || '(root)'}: ${issue.message}` }, 400);
6236
+ const refuseManifest = (issue, under = []) => refuse(c, 'validation_failed', `manifestJson is not a deploy manifest: ${[...under, ...issue.path].join('.') || '(root)'}: ${issue.message}`);
6034
6237
  const checked = storedDeployManifest.safeParse(raw);
6035
6238
  if (!checked.success)
6036
- return refuse(checked.error.issues[0]);
6239
+ return refuseManifest(checked.error.issues[0]);
6037
6240
  // #1869: a registry handed to this route now is NEW, so it answers to the push-time rule on
6038
6241
  // entity-grant shapes (no kernel namespace as an entity type), as `deployManifest` does.
6039
6242
  // An absent registry stays accepted, as above.
6040
6243
  if (checked.data.registry) {
6041
6244
  const registry = pushedPermissionRegistry.safeParse(checked.data.registry);
6042
6245
  if (!registry.success)
6043
- return refuse(registry.error.issues[0], ['registry']);
6246
+ return refuseManifest(registry.error.issues[0], ['registry']);
6044
6247
  }
6045
6248
  }
6046
6249
  await c.var.admin.publishVersion(c.get('actor'), input);
@@ -6057,7 +6260,7 @@ export function createControlPlaneApi(options) {
6057
6260
  const p = c.get('principal');
6058
6261
  const slug = await resolveVerticalId(c, c.req.param('slug'));
6059
6262
  if (await notReadable(p, slug)) {
6060
- return c.json({ error: 'not found' }, 404);
6263
+ return refuse(c, 'not_found', 'not found');
6061
6264
  }
6062
6265
  const json = await c.var.admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
6063
6266
  const registry = json ? (storedDeployManifest.parse(JSON.parse(json)).registry ?? null) : null;
@@ -6075,7 +6278,7 @@ export function createControlPlaneApi(options) {
6075
6278
  const p = c.get('principal');
6076
6279
  const slug = await resolveVerticalId(c, c.req.param('slug'));
6077
6280
  if (await notOwned(p, slug)) {
6078
- return c.json({ error: 'not found' }, 404);
6281
+ return refuse(c, 'not_found', 'not found');
6079
6282
  }
6080
6283
  // `versionMigrations` refuses a version of another vertical, so `base` cannot reach one.
6081
6284
  const migrationsOf = (id) => c.var.admin.versionMigrations(c.get('actor'), slug, id);
@@ -6097,7 +6300,7 @@ export function createControlPlaneApi(options) {
6097
6300
  const p = c.get('principal');
6098
6301
  const slug = await resolveVerticalId(c, c.req.param('slug'));
6099
6302
  if (await notReadable(p, slug)) {
6100
- return c.json({ error: 'not found' }, 404);
6303
+ return refuse(c, 'not_found', 'not found');
6101
6304
  }
6102
6305
  const json = await c.var.admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
6103
6306
  const parsed = json ? storedDeployManifest.parse(JSON.parse(json)) : null;
@@ -6120,7 +6323,7 @@ export function createControlPlaneApi(options) {
6120
6323
  const p = c.get('principal');
6121
6324
  const slug = await resolveVerticalId(c, c.req.param('slug'));
6122
6325
  if (await notReadable(p, slug)) {
6123
- return c.json({ error: 'not found' }, 404);
6326
+ return refuse(c, 'not_found', 'not found');
6124
6327
  }
6125
6328
  const json = await c.var.admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
6126
6329
  const parsed = json ? storedDeployManifest.parse(JSON.parse(json)) : null;
@@ -6148,7 +6351,7 @@ export function createControlPlaneApi(options) {
6148
6351
  const p = c.get('principal');
6149
6352
  const slug = await resolveVerticalId(c, c.req.param('slug'));
6150
6353
  if (await notReadable(p, slug)) {
6151
- return c.json({ error: 'not found' }, 404);
6354
+ return refuse(c, 'not_found', 'not found');
6152
6355
  }
6153
6356
  const json = await c.var.admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
6154
6357
  const parsed = json ? storedDeployManifest.parse(JSON.parse(json)) : null;
@@ -6167,7 +6370,7 @@ export function createControlPlaneApi(options) {
6167
6370
  const p = c.get('principal');
6168
6371
  const slug = await resolveVerticalId(c, c.req.param('slug'));
6169
6372
  if (await notReadable(p, slug)) {
6170
- return c.json({ error: 'not found' }, 404);
6373
+ return refuse(c, 'not_found', 'not found');
6171
6374
  }
6172
6375
  const json = await c.var.admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
6173
6376
  const assets = json ? (storedDeployManifest.parse(JSON.parse(json)).assets ?? null) : null;
@@ -6192,7 +6395,7 @@ export function createControlPlaneApi(options) {
6192
6395
  const p = c.get('principal');
6193
6396
  const slug = await resolveVerticalId(c, c.req.param('slug'));
6194
6397
  if (await notOwned(p, slug)) {
6195
- return c.json({ error: 'not found' }, 404);
6398
+ return refuse(c, 'not_found', 'not found');
6196
6399
  }
6197
6400
  await c.var.admin.requestPublish(c.get('actor'), slug);
6198
6401
  return c.json({ slug, requested: true });
@@ -6248,7 +6451,7 @@ export function createControlPlaneApi(options) {
6248
6451
  // 404, not 403: a slug this tenant does not own reads as absent, the same
6249
6452
  // existence-hiding reflex the registry's other per-slug reads use (K-3).
6250
6453
  if (await notOwned(c.get('principal'), slug))
6251
- return c.json({ error: 'not found' }, 404);
6454
+ return refuse(c, 'not_found', 'not found');
6252
6455
  await c.var.admin.deleteVertical(c.get('actor'), slug);
6253
6456
  return c.json({ slug, deleted: true });
6254
6457
  });
@@ -6256,7 +6459,7 @@ export function createControlPlaneApi(options) {
6256
6459
  const p = c.get('principal');
6257
6460
  const slug = await resolveVerticalId(c, c.req.param('slug'));
6258
6461
  if (await notReadable(p, slug)) {
6259
- return c.json({ error: 'not found' }, 404);
6462
+ return refuse(c, 'not_found', 'not found');
6260
6463
  }
6261
6464
  const page = pageParams(c);
6262
6465
  const entries = await c.var.admin.listChannels(c.get('actor'), slug, page);
@@ -6270,16 +6473,74 @@ export function createControlPlaneApi(options) {
6270
6473
  const slug = await resolveVerticalId(c, c.req.param('slug'));
6271
6474
  const rawChannel = c.req.param('channel');
6272
6475
  if (rawChannel !== 'prod') {
6273
- return c.json({ error: `channel '${rawChannel}' is retired — only 'prod' has history` }, 400);
6476
+ return refuse(c, 'validation_failed', `channel '${rawChannel}' is retired — only 'prod' has history`);
6274
6477
  }
6275
6478
  const channel = channelName.parse(rawChannel);
6276
6479
  if (await notOwned(p, slug)) {
6277
- return c.json({ error: 'not found' }, 404);
6480
+ return refuse(c, 'not_found', 'not found');
6278
6481
  }
6279
6482
  const page = pageParams(c);
6280
6483
  const entries = await c.var.admin.listChannelHistory(c.get('actor'), slug, channel, page);
6281
6484
  return c.json(pageOf(entries, page.limit, (e) => e.id));
6282
6485
  });
6486
+ /**
6487
+ * Puts the store scripts a version's declared stores need in place, before its vertical is
6488
+ * uploaded (#2210). A declaration with no uploader configured is refused (501) rather than
6489
+ * served without its store. A failure is recorded as an ops failure at stage `store-upload`
6490
+ * (#559), then rethrown, so the push or serve answers it and the vertical upload never runs.
6491
+ * The row carries the vertical's owner, so it shows in that tenant's failure view; the serve
6492
+ * path reads the owner only once something has failed.
6493
+ */
6494
+ const ensureScopeStoresOf = async (actor, operation, slug, stores, owner) => {
6495
+ if (stores.length === 0)
6496
+ return;
6497
+ const holder = ulid();
6498
+ const kinds = [...new Set(stores.map((store) => store.kind))].sort();
6499
+ const held = [];
6500
+ try {
6501
+ if (!options.ensureScopeStores) {
6502
+ throw new ControlPlaneError(501, `${operation} refused: '${slug}' declares a scope store and this control plane uploads no store script`);
6503
+ }
6504
+ // A shared hold on each kind (#2300): a rollout holds it exclusively, and a rebuild that read
6505
+ // a script's release before a rollout moved it would upload the old release back over it.
6506
+ // Neither the refusal nor anything else here names another vertical: a push answers its own.
6507
+ for (const kind of kinds) {
6508
+ if (!(await host.admin.holdStoreKind(actor, { kind, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS }))) {
6509
+ throw new ControlPlaneError(409, `${operation} refused: scope store kind '${kind}' is being rolled out; try again in a few minutes`);
6510
+ }
6511
+ held.push(kind);
6512
+ }
6513
+ const loadRelease = options.storeReleases
6514
+ ? storedReleaseLoader({ admin: host.admin, actor, bytes: options.storeReleases.bytes })
6515
+ : async () => undefined;
6516
+ // One kind at a time, each renewing every hold first: a push whose uploads outlast a hold
6517
+ // stops (409) rather than upload after a rollout could have taken the kind.
6518
+ for (const kind of kinds) {
6519
+ for (const each of held) {
6520
+ if (!(await host.admin.holdStoreKind(actor, { kind: each, holder, mode: 'shared', ttlMs: PUSH_HOLD_TTL_MS, renew: true }))) {
6521
+ throw new ControlPlaneError(409, `${operation} refused: the hold on scope store kind '${each}' lapsed during the upload; try again`);
6522
+ }
6523
+ }
6524
+ await options.ensureScopeStores({ vertical: slug, stores: stores.filter((store) => store.kind === kind), loadRelease });
6525
+ }
6526
+ }
6527
+ catch (e) {
6528
+ recordFailure({
6529
+ actor,
6530
+ operation: `${operation}.store-upload`,
6531
+ stage: 'store-upload',
6532
+ vertical: slug,
6533
+ tenantId: await owner().catch(() => null),
6534
+ status: e instanceof ControlPlaneError ? e.status : 502,
6535
+ message: e instanceof Error ? e.message : String(e),
6536
+ }, e);
6537
+ throw e;
6538
+ }
6539
+ finally {
6540
+ for (const kind of held)
6541
+ await host.admin.releaseStoreKindHold(actor, { kind, holder }).catch(() => undefined);
6542
+ }
6543
+ };
6283
6544
  /**
6284
6545
  * Serve a version IN PLACE (#286): upload its bundle onto the vertical's ONE stable
6285
6546
  * serving script, where every scope's data DO lives — this is the step that makes a
@@ -6332,6 +6593,8 @@ export function createControlPlaneApi(options) {
6332
6593
  ].map((b) => b.type === 'd1'
6333
6594
  ? { type: 'd1', name: b.name, id: b.id }
6334
6595
  : { type: 'r2_bucket', name: b.name, bucket_name: b.bucketName });
6596
+ // #2210: the store scripts first, so the vertical is never served before what it reaches.
6597
+ await ensureScopeStoresOf(actor, 'serve', slug, manifest.scopeStores, async () => (await ownerOf(actor, slug)) ?? null);
6335
6598
  await options.deployVertical(ref, {
6336
6599
  entry: manifest.entry,
6337
6600
  compatibilityDate: manifest.compatibilityDate,
@@ -6487,7 +6750,7 @@ export function createControlPlaneApi(options) {
6487
6750
  if (pin) {
6488
6751
  const v = await verticalOf(p.actor, slug);
6489
6752
  if (!v || v.ownerTenant !== pin)
6490
- return c.json({ error: 'forbidden' }, 403);
6753
+ return refuse(c, 'forbidden', 'forbidden');
6491
6754
  }
6492
6755
  const breaks = await promoteImpactOf(c.get('actor'), slug, channel, versionId);
6493
6756
  const { visible, otherTenants } = narrowToCaller(breaks, pin);
@@ -6500,10 +6763,8 @@ export function createControlPlaneApi(options) {
6500
6763
  // with the honest alternative — a non-prod environment is a scope with data, a preview.
6501
6764
  const rawChannel = c.req.param('channel');
6502
6765
  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);
6766
+ return refuse(c, 'validation_failed', `channel '${rawChannel}' is retired — 'prod' is the only channel. To run a version ` +
6767
+ `against non-prod data, create a preview: 'substrat preview create --tag <tag>'.`);
6507
6768
  }
6508
6769
  const channel = channelName.parse(rawChannel);
6509
6770
  const promotePin = confinedTenant(p);
@@ -6511,7 +6772,7 @@ export function createControlPlaneApi(options) {
6511
6772
  // A confined principal promotes only verticals its tenant owns.
6512
6773
  const v = await verticalOf(p.actor, slug);
6513
6774
  if (!v || v.ownerTenant !== promotePin)
6514
- return c.json({ error: 'forbidden' }, 403);
6775
+ return refuse(c, 'forbidden', 'forbidden');
6515
6776
  // …and a BUILDER promotes prod only while the vertical is PRIVATE (not listed).
6516
6777
  // A private vertical's blast radius is the owning tenant itself, and dev/staging
6517
6778
  // already run the same bundle in the same sandbox, so a staff prod gate there
@@ -6525,7 +6786,7 @@ export function createControlPlaneApi(options) {
6525
6786
  // a tenant may do inside its own. Taking the gate away from them here would be a
6526
6787
  // capability change hidden in a security fix.
6527
6788
  if (p.kind === 'builder' && channel === 'prod' && v.listed) {
6528
- return c.json({ error: 'promotion to prod is staff-only for a listed vertical' }, 403);
6789
+ return refuse(c, 'forbidden', 'promotion to prod is staff-only for a listed vertical');
6529
6790
  }
6530
6791
  }
6531
6792
  const { versionId, acknowledge } = promoteVersionBody.parse(await c.req.json());
@@ -6563,7 +6824,7 @@ export function createControlPlaneApi(options) {
6563
6824
  exportBreaks: null,
6564
6825
  exportBreaksUnavailable: `which apps it breaks could not be read: ${e instanceof Error ? e.message : String(e)}`,
6565
6826
  }));
6566
- return c.json({ error: listing.exportBreaks ? exportBreakRefusal(counted) : err.message, ...listing }, 409);
6827
+ return refuse(c, 'conflict', listing.exportBreaks ? exportBreakRefusal(counted) : err.message, listing);
6567
6828
  }
6568
6829
  // The in-place serve (#286), prod only, AFTER every promote gate has passed —
6569
6830
  // uploading first would deploy to live scopes before the acknowledgement check.
@@ -6589,10 +6850,7 @@ export function createControlPlaneApi(options) {
6589
6850
  catch (e) {
6590
6851
  const detail = e instanceof Error ? e.message : String(e);
6591
6852
  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);
6853
+ return refuseStatus(c, 502, 'promoted, but the in-place serve failed — scopes still run the previous code; promote again to retry', { detail });
6596
6854
  }
6597
6855
  }
6598
6856
  // #1756: served, and every install that could move has moved and been backfilled, but one
@@ -6601,9 +6859,8 @@ export function createControlPlaneApi(options) {
6601
6859
  // version. It is left where it is. Promoting again acknowledged moves it.
6602
6860
  if (adopted.refused.length > 0 || adopted.refusal) {
6603
6861
  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`,
6862
+ return refuse(c, 'conflict', `promoted and served, but an app still on its own version was not moved onto the serving script: ` +
6863
+ `${sentence}. Promote again with the export-break acknowledgement to move it`, {
6607
6864
  ...(adopted.refused.length > 0 ? { exportBreaks: { affected: adopted.refused } } : {}),
6608
6865
  // Narrowed as the success answer's is: a builder never reads another tenant's id here.
6609
6866
  ...(backfill.minted.length || backfill.error
@@ -6618,7 +6875,7 @@ export function createControlPlaneApi(options) {
6618
6875
  };
6619
6876
  })()
6620
6877
  : {}),
6621
- }, 409);
6878
+ });
6622
6879
  }
6623
6880
  const promoted = (await c.var.admin.listChannels(c.get('actor'), slug)).find((ch) => ch.channel === channel);
6624
6881
  // The store backfill rides the response only when it has something to say (#825), so
@@ -6656,13 +6913,13 @@ export function createControlPlaneApi(options) {
6656
6913
  // tenant); for everything else admission still gates serving.
6657
6914
  app.post('/verticals/:slug/deploy', async (c) => {
6658
6915
  if (!options.deployVertical) {
6659
- return c.json({ error: 'deploy is not configured on this control plane' }, 501);
6916
+ return refuseStatus(c, 501, 'deploy is not configured on this control plane');
6660
6917
  }
6661
6918
  const p = c.get('principal');
6662
6919
  const form = await c.req.formData();
6663
6920
  const raw = form.get('manifest');
6664
6921
  if (typeof raw !== 'string')
6665
- return c.json({ error: 'missing manifest part' }, 400);
6922
+ return refuse(c, 'validation_failed', 'missing manifest part');
6666
6923
  const manifest = deployManifest.parse(JSON.parse(raw));
6667
6924
  // §4 sandbox contract, before anything reaches the namespace.
6668
6925
  assertSandboxContract(manifest);
@@ -6699,20 +6956,20 @@ export function createControlPlaneApi(options) {
6699
6956
  let ownerTenant;
6700
6957
  if (p.kind === 'builder') {
6701
6958
  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);
6959
+ return refuse(c, 'forbidden', `push is pinned to workspace '${pin}' but this session acts for '${p.tenantSlug}'`);
6703
6960
  }
6704
6961
  slug = effectiveSlug(p, bare);
6705
6962
  // A builder pushes to a slug it owns, or claims an unregistered one (§3).
6706
6963
  const existingOwner = await ownerOf(c.get('actor'), slug);
6707
6964
  if (existingOwner !== undefined && existingOwner !== p.tenantId) {
6708
- return c.json({ error: 'forbidden' }, 403);
6965
+ return refuse(c, 'forbidden', 'forbidden');
6709
6966
  }
6710
6967
  ownerTenant = p.tenantId;
6711
6968
  }
6712
6969
  else if (pin) {
6713
6970
  const workspace = (await c.var.admin.listTenants(c.get('actor'))).find((t) => t.slug === pin || t.id === pin);
6714
6971
  if (!workspace)
6715
- return c.json({ error: `unknown workspace '${pin}'` }, 404);
6972
+ return refuse(c, 'not_found', `unknown workspace '${pin}'`);
6716
6973
  // Back-compat: a BARE slug already registered as the pinned tenant's (a staff
6717
6974
  // hand-registration predating prefixed claims) stays addressable as itself.
6718
6975
  // Otherwise the claim lands under the tenant prefix, exactly like a builder push —
@@ -6721,7 +6978,7 @@ export function createControlPlaneApi(options) {
6721
6978
  slug = bareOwner === workspace.id ? bare : `${workspace.slug}/${bare}`;
6722
6979
  const existingOwner = slug === bare ? bareOwner : await ownerOf(c.get('actor'), slug);
6723
6980
  if (existingOwner !== undefined && existingOwner !== workspace.id) {
6724
- return c.json({ error: `vertical '${slug}' is not owned by workspace '${pin}'` }, 403);
6981
+ return refuse(c, 'forbidden', `vertical '${slug}' is not owned by workspace '${pin}'`);
6725
6982
  }
6726
6983
  ownerTenant = workspace.id;
6727
6984
  }
@@ -6736,7 +6993,7 @@ export function createControlPlaneApi(options) {
6736
6993
  // registry id that predates the rule, so it can never put a script under a platform name.
6737
6994
  const collision = platformScriptCollision(slug);
6738
6995
  if (collision)
6739
- return c.json({ error: platformScriptRefusal(slug, collision) }, 422);
6996
+ return refuseStatus(c, 422, platformScriptRefusal(slug, collision));
6740
6997
  // #388: refuse the SILENT lineage fork. A first push of a registry id that does not
6741
6998
  // exist yet, whose product name (the slug's tail) matches an existing lineage this
6742
6999
  // push could CONFUSE ITSELF WITH, is almost always a mis-identified project — a
@@ -6771,13 +7028,11 @@ export function createControlPlaneApi(options) {
6771
7028
  ? 'owned by this same workspace'
6772
7029
  : `owned by tenant ${v.ownerTenant}`;
6773
7030
  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);
7031
+ return refuse(c, 'conflict', `this push would CREATE a new vertical '${slug}' — a separate lineage beside ${named}. ` +
7032
+ `Installs of the existing lineage never update from pushes to '${slug}'. If you meant to ` +
7033
+ `update it, fix the project's identity first: package.json \`substrat.slug\` and ` +
7034
+ `\`substrat.tenant\` (or --tenant) decide where a push lands. To deliberately run a ` +
7035
+ `separate same-named lineage, re-run with --allow-fork.`);
6781
7036
  }
6782
7037
  }
6783
7038
  // Two kinds of part in one body (#340): worker MODULES (unprefixed) and static ASSETS
@@ -6802,7 +7057,7 @@ export function createControlPlaneApi(options) {
6802
7057
  });
6803
7058
  }
6804
7059
  if (!modules.some((m) => m.name === manifest.entry)) {
6805
- return c.json({ error: `entry module '${manifest.entry}' is not among the uploaded files` }, 400);
7060
+ return refuse(c, 'validation_failed', `entry module '${manifest.entry}' is not among the uploaded files`);
6806
7061
  }
6807
7062
  // The static-asset half of the §4 sandbox contract (self-serve-deploy.md §4.1). The
6808
7063
  // bytes are inert — no code, no authority — so they are ACCEPTED; the content-address
@@ -6815,19 +7070,17 @@ export function createControlPlaneApi(options) {
6815
7070
  for (const file of manifest.assets?.files ?? []) {
6816
7071
  const part = assetParts.get(file.path);
6817
7072
  if (!part) {
6818
- return c.json({ error: `asset '${file.path}' is named in the manifest but was not uploaded` }, 400);
7073
+ return refuse(c, 'validation_failed', `asset '${file.path}' is named in the manifest but was not uploaded`);
6819
7074
  }
6820
7075
  const content = new Uint8Array(await part.arrayBuffer());
6821
7076
  if (content.byteLength !== file.size) {
6822
- return c.json({ error: `asset '${file.path}' declares ${file.size} bytes but ${content.byteLength} arrived` }, 400);
7077
+ return refuse(c, 'validation_failed', `asset '${file.path}' declares ${file.size} bytes but ${content.byteLength} arrived`);
6823
7078
  }
6824
7079
  const actual = await assetHash(content, file.path);
6825
7080
  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);
7081
+ return refuse(c, 'validation_failed', `asset '${file.path}' does not match its declared content hash ` +
7082
+ `(declared ${file.hash}, computed ${actual}) — the hash is the runtime's shared dedup key, ` +
7083
+ `so a mismatch is refused rather than stored`);
6831
7084
  }
6832
7085
  assets.push({
6833
7086
  path: file.path,
@@ -6842,9 +7095,7 @@ export function createControlPlaneApi(options) {
6842
7095
  // An unlisted asset part would be uploaded by nothing and served by nothing; saying so
6843
7096
  // beats silently dropping it, because the builder's page would 404 with no explanation.
6844
7097
  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);
7098
+ return refuse(c, 'validation_failed', `${assetParts.size} uploaded asset part(s) are absent from the manifest (${extra}${assetParts.size > 5 ? ', …' : ''})`);
6848
7099
  }
6849
7100
  // #1902: whether this version's uploads carry the platform's scope sweeper — decided here,
6850
7101
  // ONCE, before anything is uploaded, and recorded with the version below so every later
@@ -6852,11 +7103,14 @@ export function createControlPlaneApi(options) {
6852
7103
  // while it is still a push; it can never become a promote or a backout that refuses.
6853
7104
  const sweeper = platformSweeperDecision(manifest, { entry: manifest.entry, modules });
6854
7105
  if ('refuse' in sweeper)
6855
- return c.json({ error: sweeper.refuse }, 422);
7106
+ return refuseStatus(c, 422, sweeper.refuse);
6856
7107
  // Mint the version id first: the deploymentRef (the dispatch script name) is keyed
6857
7108
  // on it, so it is CF-valid and unique per version.
6858
7109
  const id = ulid();
6859
7110
  const deploymentRef = deploymentRefFor(slug, id);
7111
+ // #2210: the store scripts first, outside the vertical upload's try, so a store failure
7112
+ // answers as itself and the vertical is not uploaded at all.
7113
+ await ensureScopeStoresOf(c.get('actor'), 'deploy', slug, manifest.scopeStores, async () => ownerTenant ?? null);
6860
7114
  try {
6861
7115
  await options.deployVertical(deploymentRef, {
6862
7116
  entry: manifest.entry,
@@ -6905,8 +7159,8 @@ export function createControlPlaneApi(options) {
6905
7159
  }, e);
6906
7160
  c.set('failureRecorded', true);
6907
7161
  return rejected
6908
- ? c.json({ error: 'deploy rejected', detail }, 422)
6909
- : c.json({ error: 'deploy upload failed', detail }, 502);
7162
+ ? refuseStatus(c, 422, 'deploy rejected', { detail })
7163
+ : refuseStatus(c, 502, 'deploy upload failed', { detail });
6910
7164
  }
6911
7165
  // Register-then-publish, both idempotent-ish below the seam: a first push of a
6912
7166
  // slug registers it; publishVersion lands the version pending with deploymentRef
@@ -6993,7 +7247,7 @@ export function createControlPlaneApi(options) {
6993
7247
  // because "no namespaces in that script" and "I cannot look" are different answers.
6994
7248
  app.get('/platform/do-namespaces', async (c) => {
6995
7249
  if (!options.doNamespaces) {
6996
- return c.json({ error: 'durable-object namespace lookup is not configured on this control plane' }, 501);
7250
+ return refuseStatus(c, 501, 'durable-object namespace lookup is not configured on this control plane');
6997
7251
  }
6998
7252
  const { script } = z.object({ script: z.string().min(1).max(200) }).parse({ script: c.req.query('script') });
6999
7253
  return c.json(namespacesForScript(await options.doNamespaces.list(), script));
@@ -7013,7 +7267,7 @@ export function createControlPlaneApi(options) {
7013
7267
  // on the route where a mistake is least visible.
7014
7268
  app.get('/observability/metrics', async (c) => {
7015
7269
  if (!options.observability) {
7016
- return c.json({ error: 'observability is not configured on this control plane' }, 501);
7270
+ return refuseStatus(c, 501, 'observability is not configured on this control plane');
7017
7271
  }
7018
7272
  const { hours } = z
7019
7273
  .object({ hours: z.coerce.number().int().min(1).max(72).default(24) })
@@ -7030,7 +7284,7 @@ export function createControlPlaneApi(options) {
7030
7284
  // 501 when the reader cannot bucket — an empty series would render as "quiet".
7031
7285
  app.get('/observability/metrics-series', async (c) => {
7032
7286
  if (!options.observability?.serviceMetricsSeries) {
7033
- return c.json({ error: 'bucketed observability is not configured on this control plane' }, 501);
7287
+ return refuseStatus(c, 501, 'bucketed observability is not configured on this control plane');
7034
7288
  }
7035
7289
  const { hours } = z
7036
7290
  .object({ hours: z.coerce.number().int().min(1).max(72).default(24) })
@@ -7054,7 +7308,7 @@ export function createControlPlaneApi(options) {
7054
7308
  });
7055
7309
  app.get('/observability/logs', async (c) => {
7056
7310
  if (!options.observability) {
7057
- return c.json({ error: 'observability is not configured on this control plane' }, 501);
7311
+ return refuseStatus(c, 501, 'observability is not configured on this control plane');
7058
7312
  }
7059
7313
  // `service` repeats: one deployed unit per param, so a caller can ask for a
7060
7314
  // vertical's whole set (the dashboard's "all versions") in one query and get one
@@ -7100,7 +7354,7 @@ export function createControlPlaneApi(options) {
7100
7354
  // a forgotten parameter is the exact leak being closed here.
7101
7355
  app.get('/observability/tenant-metrics', async (c) => {
7102
7356
  if (!options.observability?.tenantMetrics) {
7103
- return c.json({ error: 'tenant-grain observability is not configured on this control plane' }, 501);
7357
+ return refuseStatus(c, 501, 'tenant-grain observability is not configured on this control plane');
7104
7358
  }
7105
7359
  const p = c.get('principal');
7106
7360
  const tenantId = confinedTenant(p) ?? c.req.query('tenantId');
@@ -7145,7 +7399,7 @@ export function createControlPlaneApi(options) {
7145
7399
  // buckets, and at least one is required because an unnarrowed ask is not on offer.
7146
7400
  app.get('/observability/tenant-metrics-series', async (c) => {
7147
7401
  if (!options.observability?.tenantMetricsSeries) {
7148
- return c.json({ error: 'bucketed tenant-grain observability is not configured on this control plane' }, 501);
7402
+ return refuseStatus(c, 501, 'bucketed tenant-grain observability is not configured on this control plane');
7149
7403
  }
7150
7404
  const p = c.get('principal');
7151
7405
  const tenantId = confinedTenant(p) ?? c.req.query('tenantId');
@@ -7176,7 +7430,7 @@ export function createControlPlaneApi(options) {
7176
7430
  });
7177
7431
  app.get('/observability/tenant-logs', async (c) => {
7178
7432
  if (!options.observability?.tenantLogs) {
7179
- return c.json({ error: 'tenant-grain observability is not configured on this control plane' }, 501);
7433
+ return refuseStatus(c, 501, 'tenant-grain observability is not configured on this control plane');
7180
7434
  }
7181
7435
  const p = c.get('principal');
7182
7436
  const tenantId = confinedTenant(p) ?? c.req.query('tenantId');
@@ -7234,7 +7488,7 @@ export function createControlPlaneApi(options) {
7234
7488
  // #1331: one app's sampled field reports, joined to the router's own request lines.
7235
7489
  app.get('/observability/tenant-field-coverage', async (c) => {
7236
7490
  if (!options.observability?.fieldCoverage) {
7237
- return c.json({ error: 'field coverage is not configured on this control plane' }, 501);
7491
+ return refuseStatus(c, 501, 'field coverage is not configured on this control plane');
7238
7492
  }
7239
7493
  const tenantId = confinedTenant(c.get('principal')) ?? c.req.query('tenantId');
7240
7494
  if (!tenantId)
@@ -7257,11 +7511,11 @@ export function createControlPlaneApi(options) {
7257
7511
  const scopes = await host.admin.listScopes(c.get('actor'), { tenantId: input.tenantId });
7258
7512
  const installed = scopes.find((s) => s.id === input.scopeId);
7259
7513
  if (!installed?.vertical)
7260
- return c.json({ error: 'app not found' }, 404);
7514
+ return refuse(c, 'not_found', 'app not found');
7261
7515
  const services = scriptFamiliesOfScopes(scopes, { scopeId: input.scopeId });
7262
7516
  const json = await c.var.admin.versionManifest(c.get('actor'), installed.vertical, input.versionId);
7263
7517
  if (!json)
7264
- return c.json({ error: 'version not found' }, 404);
7518
+ return refuse(c, 'not_found', 'version not found');
7265
7519
  const outputSurface = storedDeployManifest.parse(JSON.parse(json)).outputSurface ?? [];
7266
7520
  const declared = Object.fromEntries(outputSurface.map((op) => [op.operationId, op.fields]));
7267
7521
  return c.json(await options.observability.fieldCoverage({
@@ -7297,7 +7551,7 @@ export function createControlPlaneApi(options) {
7297
7551
  const appServices = async (actor, tenantId, scopeId, vertical) => scriptFamiliesOfScopes(await host.admin.listScopes(actor, { tenantId: tenantIdSchema.parse(tenantId) }), { scopeId, vertical });
7298
7552
  app.get('/observability/tenant-request-volume', async (c) => {
7299
7553
  if (!options.observability?.tenantRequestVolume) {
7300
- return c.json({ error: 'request aggregation is not configured on this control plane' }, 501);
7554
+ return refuseStatus(c, 501, 'request aggregation is not configured on this control plane');
7301
7555
  }
7302
7556
  const scope = parseTenantRequestQuery(requestTenantOf(c), requestQueryOf(c));
7303
7557
  // A bucket COUNT: zooming in keeps it and narrows the window, so the bucket shrinks.
@@ -7308,7 +7562,7 @@ export function createControlPlaneApi(options) {
7308
7562
  });
7309
7563
  app.get('/observability/tenant-request-facets', async (c) => {
7310
7564
  if (!options.observability?.tenantRequestFacets) {
7311
- return c.json({ error: 'request aggregation is not configured on this control plane' }, 501);
7565
+ return refuseStatus(c, 501, 'request aggregation is not configured on this control plane');
7312
7566
  }
7313
7567
  const scope = parseTenantRequestQuery(requestTenantOf(c), requestQueryOf(c));
7314
7568
  // `facet` repeats; absent means every facet. Each one is a backend query, so a panel
@@ -7321,7 +7575,7 @@ export function createControlPlaneApi(options) {
7321
7575
  // #1747: the tenant's `ctx.log` lines grouped by the template they were written from.
7322
7576
  app.get('/observability/tenant-log-patterns', async (c) => {
7323
7577
  if (!options.observability?.tenantLogPatterns) {
7324
- return c.json({ error: 'log patterns are not configured on this control plane' }, 501);
7578
+ return refuseStatus(c, 501, 'log patterns are not configured on this control plane');
7325
7579
  }
7326
7580
  const scope = parseTenantWindowQuery(requestTenantOf(c), requestQueryOf(c));
7327
7581
  const narrow = z
@@ -7349,7 +7603,7 @@ export function createControlPlaneApi(options) {
7349
7603
  });
7350
7604
  app.get('/observability/tenant-requests', async (c) => {
7351
7605
  if (!options.observability?.tenantRequests) {
7352
- return c.json({ error: 'request aggregation is not configured on this control plane' }, 501);
7606
+ return refuseStatus(c, 501, 'request aggregation is not configured on this control plane');
7353
7607
  }
7354
7608
  const scope = parseTenantRequestQuery(requestTenantOf(c), requestQueryOf(c));
7355
7609
  const limit = z.coerce.number().int().min(1).max(200).default(50).parse(c.req.query('limit'));
@@ -7384,7 +7638,7 @@ export function createControlPlaneApi(options) {
7384
7638
  */
7385
7639
  app.get('/verticals/:slug/egress', async (c) => {
7386
7640
  if (!options.observability?.observedEgress) {
7387
- return c.json({ error: 'observed egress is not available on this control plane' }, 501);
7641
+ return refuseStatus(c, 501, 'observed egress is not available on this control plane');
7388
7642
  }
7389
7643
  const slug = await resolveVerticalId(c, c.req.param('slug'));
7390
7644
  const { hours, limit } = z
@@ -7455,12 +7709,12 @@ export function createControlPlaneApi(options) {
7455
7709
  // can NOT do (prod promote, admit, other tenants' slugs) holds for it identically.
7456
7710
  app.post('/push-tokens', async (c) => {
7457
7711
  if (!options.pushTokenSecret) {
7458
- return c.json({ error: 'push tokens are not configured on this control plane' }, 501);
7712
+ return refuseStatus(c, 501, 'push tokens are not configured on this control plane');
7459
7713
  }
7460
7714
  const { tenantId } = z.object({ tenantId: tenantIdSchema }).parse(await c.req.json());
7461
7715
  const tenant = await c.var.admin.getTenant(c.get('actor'), tenantId);
7462
7716
  if (!tenant)
7463
- return c.json({ error: `unknown tenant: ${tenantId}` }, 404);
7717
+ return refuse(c, 'not_found', `unknown tenant: ${tenantId}`);
7464
7718
  const token = await mintPushToken(options.pushTokenSecret, {
7465
7719
  actor: await pushActorFor(tenantId),
7466
7720
  tenantId,
@@ -7477,7 +7731,7 @@ export function createControlPlaneApi(options) {
7477
7731
  // ONLY fleet-wide thing it does. Everything else it does is spent per tenant.
7478
7732
  app.post('/tenant-tokens', async (c) => {
7479
7733
  if (!options.tenantTokenSecret) {
7480
- return c.json({ error: 'tenant tokens are not configured on this control plane' }, 501);
7734
+ return refuseStatus(c, 501, 'tenant tokens are not configured on this control plane');
7481
7735
  }
7482
7736
  const { tenantId, principal, impersonation } = z
7483
7737
  .object({
@@ -7604,7 +7858,7 @@ export function createControlPlaneApi(options) {
7604
7858
  // The body names the tenant the binding lands under; a confined principal may
7605
7859
  // only name its own (the adapter then verifies the scope belongs to it, K-3).
7606
7860
  if (input.tenantId !== bindPin)
7607
- return c.json({ error: 'forbidden' }, 403);
7861
+ return refuse(c, 'forbidden', 'forbidden');
7608
7862
  }
7609
7863
  if (p.kind === 'builder') {
7610
7864
  // The region column is an EU-residency claim (K-30) — never builder-suppliable.
@@ -7613,7 +7867,7 @@ export function createControlPlaneApi(options) {
7613
7867
  // the binding sits, so widening this to #977's credential would be a behaviour
7614
7868
  // change wearing a security fix's clothes.
7615
7869
  if (input.region !== null) {
7616
- return c.json({ error: 'region is derived from the scope, not chosen on a binding' }, 403);
7870
+ return refuse(c, 'forbidden', 'region is derived from the scope, not chosen on a binding');
7617
7871
  }
7618
7872
  }
7619
7873
  // Registrable-suffix guard (#305, D-35): a custom domain must be a real registrable
@@ -7622,7 +7876,7 @@ export function createControlPlaneApi(options) {
7622
7876
  if (custom(input.hostname)) {
7623
7877
  const bad = validateBindableHostname(input.hostname);
7624
7878
  if (bad)
7625
- return c.json({ error: bad }, 422);
7879
+ return refuseStatus(c, 422, bad);
7626
7880
  }
7627
7881
  await c.var.admin.bindHostname(c.get('actor'), input);
7628
7882
  const bound = (await c.var.admin.listHostnames(c.get('actor'), { scopeId: input.scopeId })).find((h) => h.hostname === input.hostname);
@@ -7639,11 +7893,11 @@ export function createControlPlaneApi(options) {
7639
7893
  app.post('/hostnames/:hostname/verify', async (c) => {
7640
7894
  const name = c.req.param('hostname');
7641
7895
  if (confinedTenant(c.get('principal')) !== null && !(await tenantHostname(c, name))) {
7642
- return c.json({ error: `unknown hostname: ${name.toLowerCase()}` }, 404);
7896
+ return refuse(c, 'not_found', `unknown hostname: ${name.toLowerCase()}`);
7643
7897
  }
7644
7898
  const row = await hostnameRow(c, name);
7645
7899
  if (!row)
7646
- return c.json({ error: `unknown hostname: ${name.toLowerCase()}` }, 404);
7900
+ return refuse(c, 'not_found', `unknown hostname: ${name.toLowerCase()}`);
7647
7901
  await runIssuance(c.var.admin, c.get('actor'), row);
7648
7902
  return c.json(await hostnameRow(c, name));
7649
7903
  });
@@ -7653,7 +7907,7 @@ export function createControlPlaneApi(options) {
7653
7907
  // `setHostnameStatus` normalizes and 404s an unknown one below the seam.
7654
7908
  const name = c.req.param('hostname');
7655
7909
  if (confinedTenant(c.get('principal')) !== null && !(await tenantHostname(c, name))) {
7656
- return c.json({ error: `unknown hostname: ${name.toLowerCase()}` }, 404);
7910
+ return refuse(c, 'not_found', `unknown hostname: ${name.toLowerCase()}`);
7657
7911
  }
7658
7912
  await c.var.admin.setHostnameStatus(c.get('actor'), name, status, note);
7659
7913
  const row = (await c.var.admin.listHostnames(c.get('actor'), {})).find((h) => h.hostname === name.toLowerCase());
@@ -7667,7 +7921,7 @@ export function createControlPlaneApi(options) {
7667
7921
  app.delete('/hostnames/:hostname', async (c) => {
7668
7922
  const name = c.req.param('hostname');
7669
7923
  if (confinedTenant(c.get('principal')) !== null && !(await tenantHostname(c, name))) {
7670
- return c.json({ error: `unknown hostname: ${name.toLowerCase()}` }, 404);
7924
+ return refuse(c, 'not_found', `unknown hostname: ${name.toLowerCase()}`);
7671
7925
  }
7672
7926
  // Release the Cloudflare custom hostname before dropping the row, or the CF object
7673
7927
  // leaks (billable, and it would block a future rebind of the same name). Best-effort:
@@ -7717,27 +7971,35 @@ export function createControlPlaneApi(options) {
7717
7971
  * reaps a HALF-BUILT leftover before re-forking (see `orchestratedPreview`). */
7718
7972
  /** Answers whether some of the preview's storage was stranded (and recorded as such). */
7719
7973
  const reapPreview = async (c, preview) => {
7720
- // A fork's own sign-in clients go FIRST (#1704): a failure here leaves the preview in
7974
+ const actor = c.get('actor');
7975
+ const dispatch = options.resolveVerticalRef;
7976
+ // #2285: every refusal that can be decided up front comes before anything is destroyed, so a
7977
+ // refused reap (a copy move in flight, a copy nothing can reach, stores that cannot be named)
7978
+ // leaves the preview routed, signing in, and whole.
7979
+ if (dispatch)
7980
+ await claimScopeScriptReap({ admin: c.var.admin, actor }, preview.tenantId, preview.id);
7981
+ else
7982
+ await assertNoUnreachableScopeCopies(c.var.admin, actor, preview.tenantId, preview.id);
7983
+ const wipeStores = await platformStoresWipe(c, preview);
7984
+ // A preview is still routed until its row goes: unroute before the wipe.
7985
+ await unrouteScope(c.var.admin, actor, preview);
7986
+ // A fork's own sign-in clients go once nothing routes to it, and before the row (#1704): an
7987
+ // unroute that fails leaves the preview signing in, and a failure here leaves the preview in
7721
7988
  // place, so the retry a PR-close job makes still finds it — after the row is gone, nothing
7722
7989
  // would name those clients again. A clean room never had one.
7723
7990
  await retireClientsOfReapedScope(previewAuthDeps(c), preview);
7724
- await wipePlatformStores(c, preview);
7991
+ await wipeStores();
7725
7992
  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));
7993
+ if (dispatch) {
7994
+ ({ storageStranded } = await drainScopeScriptCopies({ admin: c.var.admin, actor, resolveRef: dispatch }, preview.tenantId, preview.id));
7732
7995
  }
7733
7996
  else {
7734
- await assertNoUnreachableScopeCopies(c.var.admin, c.get('actor'), preview.tenantId, preview.id);
7735
7997
  const vertical = await verticalForScope(c, preview);
7736
7998
  if (vertical)
7737
7999
  await vertical.deleteScope({ tenantId: preview.tenantId, scopeId: preview.id });
7738
8000
  }
7739
8001
  // 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);
8002
+ await c.var.host.deleteSnapshot(actor, preview.tenantId, preview.id);
7741
8003
  return { storageStranded };
7742
8004
  };
7743
8005
  /** The preview-login seam (#1704, `preview-auth.ts`): the directory, and each issuer's deployment. */
@@ -8171,23 +8433,23 @@ export function createControlPlaneApi(options) {
8171
8433
  const slug = await resolveVerticalId(c, c.req.param('slug'));
8172
8434
  const v = await verticalOf(c.get('actor'), slug);
8173
8435
  if (!v)
8174
- return { error: `unknown vertical '${slug}'`, status: 404 };
8436
+ return { refused: 'not_found', error: `unknown vertical '${slug}'` };
8175
8437
  if (confinedTenant(p) !== null && v.ownerTenant !== confinedTenant(p)) {
8176
- return { error: 'forbidden', status: 403 };
8438
+ return { refused: 'forbidden', error: 'forbidden' };
8177
8439
  }
8178
8440
  if (v.ownerTenant === null)
8179
- return { error: `vertical '${slug}' has no owner tenant`, status: 409 };
8441
+ return { refused: 'conflict', error: `vertical '${slug}' has no owner tenant` };
8180
8442
  return { tenantId: v.ownerTenant, slug };
8181
8443
  };
8182
8444
  app.post('/verticals/:slug/previews', async (c) => {
8183
8445
  const gate = await previewVertical(c);
8184
8446
  if ('error' in gate)
8185
- return c.json({ error: gate.error }, gate.status);
8447
+ return refuse(c, gate.refused, gate.error);
8186
8448
  const { tenantId, slug } = gate;
8187
8449
  const body = createPreviewBody.parse(await c.req.json());
8188
8450
  const actor = c.get('actor');
8189
8451
  if (body.empty && body.sourceScopeId) {
8190
- return c.json({ error: `pass either --empty (clean-room) or a sourceScopeId, not both` }, 400);
8452
+ return refuse(c, 'validation_failed', `pass either --empty (clean-room) or a sourceScopeId, not both`);
8191
8453
  }
8192
8454
  // Clean-room (#509 (b)): a null source provisions an EMPTY scope — no prod scope needed.
8193
8455
  // Everything else forks the tenant's own active, non-fork scope for this vertical.
@@ -8201,12 +8463,12 @@ export function createControlPlaneApi(options) {
8201
8463
  : null;
8202
8464
  if (!source) {
8203
8465
  if (body.sourceScopeId) {
8204
- return c.json({ error: `no active prod scope ${body.sourceScopeId} for '${slug}'` }, 404);
8466
+ return refuse(c, 'not_found', `no active prod scope ${body.sourceScopeId} for '${slug}'`);
8205
8467
  }
8206
8468
  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);
8469
+ return refuse(c, 'conflict', `no prod scope to fork for '${slug}' — provision one first, or pass empty:true for a clean-room preview`);
8208
8470
  }
8209
- return c.json({ error: `'${slug}' has several prod scopes — pass sourceScopeId`, scopes: owned.map((s) => s.id) }, 409);
8471
+ return refuse(c, 'conflict', `'${slug}' has several prod scopes — pass sourceScopeId`, { scopes: owned.map((s) => s.id) });
8210
8472
  }
8211
8473
  }
8212
8474
  try {
@@ -8215,14 +8477,14 @@ export function createControlPlaneApi(options) {
8215
8477
  }
8216
8478
  catch (e) {
8217
8479
  if (e instanceof ControlPlaneError)
8218
- return c.json({ error: e.message }, failedWith(c, e));
8480
+ return relayRefusal(c, e);
8219
8481
  throw e;
8220
8482
  }
8221
8483
  });
8222
8484
  app.get('/verticals/:slug/previews', async (c) => {
8223
8485
  const gate = await previewVertical(c);
8224
8486
  if ('error' in gate)
8225
- return c.json({ error: gate.error }, gate.status);
8487
+ return refuse(c, gate.refused, gate.error);
8226
8488
  const { tenantId, slug } = gate;
8227
8489
  const actor = c.get('actor');
8228
8490
  const previews = (await c.var.admin.listScopes(actor, { tenantId, vertical: slug }))
@@ -8248,7 +8510,7 @@ export function createControlPlaneApi(options) {
8248
8510
  app.delete('/verticals/:slug/previews/:tag', async (c) => {
8249
8511
  const gate = await previewVertical(c);
8250
8512
  if ('error' in gate)
8251
- return c.json({ error: gate.error }, gate.status);
8513
+ return refuse(c, gate.refused, gate.error);
8252
8514
  const { tenantId, slug } = gate;
8253
8515
  const tag = c.req.param('tag');
8254
8516
  const actor = c.get('actor');
@@ -8266,7 +8528,7 @@ export function createControlPlaneApi(options) {
8266
8528
  }
8267
8529
  catch (e) {
8268
8530
  if (e instanceof ControlPlaneError)
8269
- return c.json({ error: e.message }, failedWith(c, e));
8531
+ return relayRefusal(c, e);
8270
8532
  throw e;
8271
8533
  }
8272
8534
  });
@@ -8353,7 +8615,7 @@ export function createControlPlaneApi(options) {
8353
8615
  // not be able to switch itself back on, and the dashboard has no flow that pulls it.
8354
8616
  const switchScheduleRoute = (to) => async (c) => {
8355
8617
  if (confinedTenant(c.get('principal')) !== null) {
8356
- return c.json({ error: 'forbidden: the schedule switch is staff-only' }, 403);
8618
+ return refuse(c, 'forbidden', 'forbidden: the schedule switch is staff-only');
8357
8619
  }
8358
8620
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
8359
8621
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
@@ -8361,7 +8623,7 @@ export function createControlPlaneApi(options) {
8361
8623
  const actor = c.get('actor');
8362
8624
  // K-3 first, so a scope of another tenant reads as absent before anything is reached.
8363
8625
  if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId))) {
8364
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
8626
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
8365
8627
  }
8366
8628
  const input = { moduleId: body.moduleId, node: { tenantId, scopeId }, reason: body.reason };
8367
8629
  return c.json(to === 'off' ? await c.var.admin.revokeFromSystem(actor, input) : await c.var.admin.restoreToSystem(actor, input));
@@ -8375,14 +8637,14 @@ export function createControlPlaneApi(options) {
8375
8637
  // `confinedTenant` check, not a kind test.
8376
8638
  app.get('/tenants/:tenantId/scopes/:scopeId/system-grants', async (c) => {
8377
8639
  if (confinedTenant(c.get('principal')) !== null) {
8378
- return c.json({ error: 'forbidden: the schedule switch is staff-only' }, 403);
8640
+ return refuse(c, 'forbidden', 'forbidden: the schedule switch is staff-only');
8379
8641
  }
8380
8642
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
8381
8643
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
8382
8644
  const actor = c.get('actor');
8383
8645
  // K-3 first, so a scope of another tenant reads as absent before anything is reached.
8384
8646
  if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId))) {
8385
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
8647
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
8386
8648
  }
8387
8649
  return c.json(await c.var.admin.systemGrantsStatus(actor, { tenantId, scopeId }));
8388
8650
  });
@@ -8397,7 +8659,7 @@ export function createControlPlaneApi(options) {
8397
8659
  // it must not start answering if this path is ever allowlisted for a narrowed read.
8398
8660
  app.get('/system-switches', async (c) => {
8399
8661
  if (confinedTenant(c.get('principal')) !== null) {
8400
- return c.json({ error: 'forbidden: the schedule switch is staff-only' }, 403);
8662
+ return refuse(c, 'forbidden', 'forbidden: the schedule switch is staff-only');
8401
8663
  }
8402
8664
  const q = systemSwitchesQuery.parse({
8403
8665
  position: c.req.query('position'),
@@ -8435,12 +8697,12 @@ export function createControlPlaneApi(options) {
8435
8697
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
8436
8698
  const pin = confinedTenant(c.get('principal'));
8437
8699
  if (pin !== null && pin !== tenantId)
8438
- return c.json({ error: 'forbidden' }, 403);
8700
+ return refuse(c, 'forbidden', 'forbidden');
8439
8701
  const body = peerSwitchBody.parse(await c.req.json());
8440
8702
  const actor = c.get('actor');
8441
8703
  // K-3 first, so a scope of another tenant reads as absent before anything is reached.
8442
8704
  if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId))) {
8443
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
8705
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
8444
8706
  }
8445
8707
  const input = { vertical: body.vertical, node: { tenantId, scopeId }, reason: body.reason };
8446
8708
  return c.json(to === 'off' ? await c.var.admin.revokeFromPeer(actor, input) : await c.var.admin.restoreToPeer(actor, input));
@@ -8455,10 +8717,10 @@ export function createControlPlaneApi(options) {
8455
8717
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
8456
8718
  const pin = confinedTenant(c.get('principal'));
8457
8719
  if (pin !== null && pin !== tenantId)
8458
- return c.json({ error: 'forbidden' }, 403);
8720
+ return refuse(c, 'forbidden', 'forbidden');
8459
8721
  const actor = c.get('actor');
8460
8722
  if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId))) {
8461
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
8723
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
8462
8724
  }
8463
8725
  return c.json(await c.var.admin.peerGrantsStatus(actor, { tenantId, scopeId }));
8464
8726
  });
@@ -8471,10 +8733,10 @@ export function createControlPlaneApi(options) {
8471
8733
  const vertical = c.req.query('vertical');
8472
8734
  const pin = confinedTenant(c.get('principal'));
8473
8735
  if (pin !== null && pin !== tenantId)
8474
- return c.json({ error: 'forbidden' }, 403);
8736
+ return refuse(c, 'forbidden', 'forbidden');
8475
8737
  const actor = c.get('actor');
8476
8738
  if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId)))
8477
- return c.json({ error: 'app not found' }, 404);
8739
+ return refuse(c, 'not_found', 'app not found');
8478
8740
  return c.json(vertical === undefined
8479
8741
  ? await c.var.admin.peerBindings(actor, tenantId, scopeId)
8480
8742
  : await c.var.admin.peerBinding(actor, tenantId, scopeId, verticalSlug.parse(vertical)) ?? null);
@@ -8486,11 +8748,11 @@ export function createControlPlaneApi(options) {
8486
8748
  // The target choice is the tenant's alone. Staff may inspect it through GET, but cannot
8487
8749
  // choose a tenant's site on its behalf.
8488
8750
  if (pin !== tenantId)
8489
- return c.json({ error: 'forbidden' }, 403);
8751
+ return refuse(c, 'forbidden', 'forbidden');
8490
8752
  const body = peerBindingBody.parse(await c.req.json());
8491
8753
  const actor = c.get('actor');
8492
8754
  if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId)))
8493
- return c.json({ error: 'app not found' }, 404);
8755
+ return refuse(c, 'not_found', 'app not found');
8494
8756
  const operationId = ulid();
8495
8757
  const base = { tenantId, scopeId, operationId, vertical: body.vertical, targetScopeId: body.targetScopeId };
8496
8758
  const done = await auditedChange({
@@ -8605,16 +8867,16 @@ export function createControlPlaneApi(options) {
8605
8867
  const scopeId = scopeIdSchema.parse(c.req.param('scopeId'));
8606
8868
  const pin = confinedTenant(c.get('principal'));
8607
8869
  if (pin !== null && pin !== tenantId)
8608
- return c.json({ error: 'forbidden' }, 403);
8870
+ return refuse(c, 'forbidden', 'forbidden');
8609
8871
  const raw = await c.req.json().catch(() => ({}));
8610
8872
  const refused = importCursorAcknowledgementMissing(raw);
8611
8873
  if (refused)
8612
- return c.json({ error: refused }, 400);
8874
+ return refuse(c, 'validation_failed', refused);
8613
8875
  const move = importCursorMove.parse(raw);
8614
8876
  const actor = c.get('actor');
8615
8877
  // K-3 first, so a scope of another tenant reads as absent before anything is reached.
8616
8878
  if (!(await c.var.admin.getScopeRecord(actor, tenantId, scopeId))) {
8617
- return c.json({ error: `unknown scope for tenant: (${tenantId}, ${scopeId})` }, 404);
8879
+ return refuse(c, 'not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
8618
8880
  }
8619
8881
  return c.json(await c.var.admin.moveImportCursor(actor, tenantId, scopeId, move));
8620
8882
  });
@@ -8627,7 +8889,7 @@ export function createControlPlaneApi(options) {
8627
8889
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
8628
8890
  const pin = confinedTenant(c.get('principal'));
8629
8891
  if (pin !== null && pin !== tenantId)
8630
- return c.json({ error: 'forbidden' }, 403);
8892
+ return refuse(c, 'forbidden', 'forbidden');
8631
8893
  const focus = c.req.query('scopeId');
8632
8894
  return c.json(await crossVerticalHealth(c.var.host, {
8633
8895
  actor: c.get('actor'),
@@ -8659,7 +8921,7 @@ export function createControlPlaneApi(options) {
8659
8921
  const input = issueStatusUpdate.parse(await c.req.json());
8660
8922
  const updated = await c.var.admin.setIssueStatus(c.get('actor'), input.fingerprint, input.status);
8661
8923
  if (!updated)
8662
- return c.json({ error: 'unknown issue fingerprint' }, 404);
8924
+ return refuse(c, 'not_found', 'unknown issue fingerprint');
8663
8925
  return c.json(updated);
8664
8926
  });
8665
8927
  // -- findings (#1748): the tenant inbox, with a lifecycle and suppress rules ----
@@ -8682,17 +8944,17 @@ export function createControlPlaneApi(options) {
8682
8944
  app.put('/tenants/:tenantId/findings/:id/status', async (c) => {
8683
8945
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
8684
8946
  if (outsideTenant(c.get('principal'), tenantId))
8685
- return c.json({ error: 'forbidden' }, 403);
8947
+ return refuse(c, 'forbidden', 'forbidden');
8686
8948
  const { status } = findingStatusUpdate.parse(await c.req.json());
8687
8949
  const updated = await c.var.admin.setFindingStatus(c.get('actor'), tenantId, c.req.param('id'), status);
8688
8950
  if (!updated)
8689
- return c.json({ error: 'unknown finding' }, 404);
8951
+ return refuse(c, 'not_found', 'unknown finding');
8690
8952
  return c.json(updated);
8691
8953
  });
8692
8954
  app.get('/tenants/:tenantId/finding-rules', async (c) => {
8693
8955
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
8694
8956
  if (outsideTenant(c.get('principal'), tenantId))
8695
- return c.json({ error: 'forbidden' }, 403);
8957
+ return refuse(c, 'forbidden', 'forbidden');
8696
8958
  const filter = findingRulesQuery.parse({ active: c.req.query('active'), limit: c.req.query('limit') });
8697
8959
  const entries = await c.var.admin.listFindingRules(c.get('actor'), tenantId, filter);
8698
8960
  return c.json({ entries });
@@ -8702,17 +8964,17 @@ export function createControlPlaneApi(options) {
8702
8964
  app.post('/tenants/:tenantId/finding-rules', async (c) => {
8703
8965
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
8704
8966
  if (outsideTenant(c.get('principal'), tenantId))
8705
- return c.json({ error: 'forbidden' }, 403);
8967
+ return refuse(c, 'forbidden', 'forbidden');
8706
8968
  const input = findingRuleInput.parse(await c.req.json());
8707
8969
  return c.json(await c.var.admin.createFindingRule(c.get('actor'), tenantId, input), 201);
8708
8970
  });
8709
8971
  app.delete('/tenants/:tenantId/finding-rules/:ruleId', async (c) => {
8710
8972
  const tenantId = tenantIdSchema.parse(c.req.param('tenantId'));
8711
8973
  if (outsideTenant(c.get('principal'), tenantId))
8712
- return c.json({ error: 'forbidden' }, 403);
8974
+ return refuse(c, 'forbidden', 'forbidden');
8713
8975
  const revoked = await c.var.admin.revokeFindingRule(c.get('actor'), tenantId, c.req.param('ruleId'));
8714
8976
  if (!revoked)
8715
- return c.json({ error: 'unknown finding rule' }, 404);
8977
+ return refuse(c, 'not_found', 'unknown finding rule');
8716
8978
  return c.json(revoked);
8717
8979
  });
8718
8980
  // -- model usage (#1054): meter 3, the one D-30 could not compute -------------
@@ -8748,6 +9010,166 @@ export function createControlPlaneApi(options) {
8748
9010
  const summary = await c.var.admin.summarizeModelUsage(c.get('actor'), { ...(q.tenantId ? { tenantId: q.tenantId } : {}), since, until }, options.modelMarginPercent ?? DEFAULT_MODEL_MARGIN_PERCENT);
8749
9011
  return c.json(summary);
8750
9012
  });
9013
+ // -- store-kind releases (D-63, #2300) ----------------------------------------
9014
+ // Staff only: none of these is on the builder or the tenant allowlist. A write is also made by a
9015
+ // person: `humanStaffActor` must name the same actor from a human staff sign-in, so the
9016
+ // automation token, which resolves to a staff actor as well, publishes nothing.
9017
+ const registeredKind = (kind) => {
9018
+ const registration = (options.storeKinds ?? STORE_KINDS).find((k) => k.kind === kind);
9019
+ if (!registration)
9020
+ throw new ControlPlaneError(403, `scope store kind '${kind}' is not registered`);
9021
+ return registration;
9022
+ };
9023
+ const releaseFlow = () => {
9024
+ if (!options.storeReleases || !options.ensureScopeStores) {
9025
+ throw new ControlPlaneError(501, 'this control plane keeps no store kind releases');
9026
+ }
9027
+ return { flow: options.storeReleases, ensureScopeStores: options.ensureScopeStores };
9028
+ };
9029
+ /**
9030
+ * A write made by a person, on purpose. The staff session also rides a cookie, which a browser
9031
+ * sends on a same-site request a page fires, so these writes take JSON only (a form or a
9032
+ * `text/plain` beacon cannot set it without a CORS preflight, 415) and refuse a request a
9033
+ * browser marks as from another origin (403). A request with neither header is the CLI's.
9034
+ */
9035
+ const byAPerson = async (c) => {
9036
+ if (!/^application\/json\s*(;|$)/i.test(c.req.header('content-type') ?? '')) {
9037
+ throw new ControlPlaneError(415, 'a store kind release write takes application/json');
9038
+ }
9039
+ const origin = c.req.header('origin');
9040
+ const site = c.req.header('sec-fetch-site');
9041
+ if ((origin && origin !== new URL(c.req.url).origin) || (site && site !== 'same-origin' && site !== 'none')) {
9042
+ throw new ControlPlaneError(403, 'a store kind release write is not taken from another origin');
9043
+ }
9044
+ const actor = c.get('actor');
9045
+ const person = options.humanStaffActor ? await options.humanStaffActor(c.req.raw) : null;
9046
+ if (!person || person !== actor) {
9047
+ throw new ControlPlaneError(403, 'a store kind release is published by a person: this takes a staff sign-in, not a service token');
9048
+ }
9049
+ return actor;
9050
+ };
9051
+ /** The pin a release is run under, or a refusal: the platform runs only what this repo pins. */
9052
+ const pinOf = async (c, registration, sha256) => {
9053
+ const stored = await c.var.admin.getStoreKindRelease(c.get('actor'), registration.kind, sha256);
9054
+ if (!stored)
9055
+ throw new ControlPlaneError(404, `release ${sha256} of '${registration.kind}' is not stored`);
9056
+ const pin = registration.releases.find((p) => p.sha256 === sha256);
9057
+ if (!pin) {
9058
+ throw new ControlPlaneError(422, `release ${sha256} of '${registration.kind}' is not pinned in its registration; it runs once a reviewed change pins it and the control plane is deployed`);
9059
+ }
9060
+ if (pin.sourceCommit !== stored.sourceCommit) {
9061
+ throw new ControlPlaneError(422, `release ${sha256} is pinned at ${pin.sourceCommit} and was stored as built from ${stored.sourceCommit}`);
9062
+ }
9063
+ return pin;
9064
+ };
9065
+ const rollOut = async (c, registration, sha256, action) => {
9066
+ const { flow, ensureScopeStores } = releaseFlow();
9067
+ const actor = c.get('actor');
9068
+ const loadRelease = storedReleaseLoader({ admin: c.var.admin, actor, bytes: flow.bytes });
9069
+ const result = await runStoreKindRollout({
9070
+ admin: c.var.admin,
9071
+ actor,
9072
+ registration,
9073
+ sha256,
9074
+ action,
9075
+ ensure: async (input) => {
9076
+ const result = await ensureScopeStores({ ...input, loadRelease });
9077
+ return result ? result.uploaded > 0 : true;
9078
+ },
9079
+ ensureCanary: (to) => flow.ensureCanary({ kind: registration.kind, to, loadRelease }),
9080
+ callCanary: flow.callCanary,
9081
+ });
9082
+ return c.json(result, result.complete ? 200 : 202);
9083
+ };
9084
+ /** Neither a rollout nor a rollback lowers the stored schema: a kind migrates forward only. */
9085
+ const assertSchemaKept = (registration, from, to) => {
9086
+ if (from === null || from === to.sha256)
9087
+ return;
9088
+ const leaving = registration.releases.find((p) => p.sha256 === from);
9089
+ if (!leaving)
9090
+ throw new ControlPlaneError(409, `the current release ${from} of '${registration.kind}' is not pinned, so its schema cannot be judged`);
9091
+ if (to.schema < leaving.schema) {
9092
+ throw new ControlPlaneError(409, `release ${to.sha256} has schema ${to.schema}, below the current release's ${leaving.schema}: objects may already have migrated`);
9093
+ }
9094
+ };
9095
+ app.get('/store-kinds/:kind', async (c) => {
9096
+ const registration = registeredKind(c.req.param('kind'));
9097
+ const state = await c.var.admin.storeKindState(c.get('actor'), registration.kind);
9098
+ return c.json({ kind: registration.kind, pins: registration.releases, ...state });
9099
+ });
9100
+ // Store a release's bytes, before or after its pin lands (D-63). Idempotent per bundle.
9101
+ app.put('/store-kinds/:kind/releases', async (c) => {
9102
+ const registration = registeredKind(c.req.param('kind'));
9103
+ const actor = await byAPerson(c);
9104
+ const { flow } = releaseFlow();
9105
+ const body = await readCappedJson(c.req.raw, STORE_RELEASE_REQUEST_MAX_BYTES);
9106
+ const release = await storeKindRelease({ admin: c.var.admin, actor, bytes: flow.bytes, kind: registration.kind, body });
9107
+ return c.json({ release, pinned: registration.releases.some((p) => p.sha256 === release.sha256 && p.sourceCommit === release.sourceCommit) }, 201);
9108
+ });
9109
+ // Roll a stored, pinned release out: the canary, then every vertical that ever declared the
9110
+ // kind. Posting the same release again converges a rollout that stopped.
9111
+ app.post('/store-kinds/:kind/rollouts', async (c) => {
9112
+ const registration = registeredKind(c.req.param('kind'));
9113
+ await byAPerson(c);
9114
+ const { sha256 } = z.object({ sha256: z.string().regex(/^[0-9a-f]{64}$/) }).strict().parse(await c.req.json());
9115
+ const pin = await pinOf(c, registration, sha256);
9116
+ const { current } = await c.var.admin.storeKindState(c.get('actor'), registration.kind);
9117
+ assertSchemaKept(registration, current?.sha256 ?? null, pin);
9118
+ return rollOut(c, registration, sha256, 'rollout');
9119
+ });
9120
+ // Return to the immediately previous good release (D-63), never further and never a guess. After
9121
+ // a rollout that ended diverged, that is the current good release itself: undoing the bad
9122
+ // rollout converges every script back on the release the fleet was healthy on (kernel
9123
+ // `storeKindStateIn`, `rollbackTarget`).
9124
+ app.post('/store-kinds/:kind/rollback', async (c) => {
9125
+ const registration = registeredKind(c.req.param('kind'));
9126
+ await byAPerson(c);
9127
+ const { current, rollbackTarget } = await c.var.admin.storeKindState(c.get('actor'), registration.kind);
9128
+ if (!current || !rollbackTarget)
9129
+ throw new ControlPlaneError(409, `kind '${registration.kind}' has no previous good release to roll back to`);
9130
+ const pin = await pinOf(c, registration, rollbackTarget);
9131
+ assertSchemaKept(registration, current.sha256, pin);
9132
+ return rollOut(c, registration, rollbackTarget, 'rollback');
9133
+ });
8751
9134
  return app;
8752
9135
  }
9136
+ /**
9137
+ * The most a release upload's body may be: the decoded cap, as base64 (4 bytes per 3), plus room
9138
+ * for the JSON around it. A bigger body is refused (413) before it is read whole.
9139
+ */
9140
+ const STORE_RELEASE_REQUEST_MAX_BYTES = Math.ceil((STORE_RELEASE_MAX_BYTES * 4) / 3) + 1024 * 1024;
9141
+ /** A JSON body read to at most `max` bytes: a declared length over it, or a stream past it, is 413. */
9142
+ async function readCappedJson(request, max) {
9143
+ const declared = Number(request.headers.get('content-length') ?? NaN);
9144
+ if (Number.isFinite(declared) && declared > max)
9145
+ throw new ControlPlaneError(413, `the body is over ${max} bytes`);
9146
+ if (!request.body)
9147
+ throw new ControlPlaneError(422, 'the body is empty');
9148
+ const reader = request.body.getReader();
9149
+ const chunks = [];
9150
+ let total = 0;
9151
+ for (;;) {
9152
+ const { done, value } = await reader.read();
9153
+ if (done)
9154
+ break;
9155
+ total += value.byteLength;
9156
+ if (total > max) {
9157
+ await reader.cancel().catch(() => undefined);
9158
+ throw new ControlPlaneError(413, `the body is over ${max} bytes`);
9159
+ }
9160
+ chunks.push(value);
9161
+ }
9162
+ const bytes = new Uint8Array(total);
9163
+ let offset = 0;
9164
+ for (const chunk of chunks) {
9165
+ bytes.set(chunk, offset);
9166
+ offset += chunk.byteLength;
9167
+ }
9168
+ try {
9169
+ return JSON.parse(new TextDecoder().decode(bytes));
9170
+ }
9171
+ catch {
9172
+ throw new ControlPlaneError(422, 'the body is not JSON');
9173
+ }
9174
+ }
8753
9175
  //# sourceMappingURL=api.js.map