observation-class finding, recorded while fixing #7678 (PR #7979). Not fixed there — that card was scoped to the ?status validation, and this is a separate, wider shape. Filed unassigned.
Fact
The three /api/v1/security/suggested-bindings routes in packages/rest/src/rest-server.ts → registerSecurityEndpoints emit three mutually incompatible error envelopes, depending only on which arm refuses:
| arm | shape | where |
|---|
repeated query param (#6877) — and, since #7678, unknown ?status | { error: { code, message } } | refuseRepeatedQueryParams / the new status guard |
| service not registered | { code, message } — no error wrapper at all | respond501 |
| thrown service error (403 / 404 / 409 / 500) | { code, error: "<string>" } — error is a bare string | handleError |
So a client cannot read one field to learn why its call failed on these routes: error is an object in one arm, absent in another, and a human-readable string in the third — and the semantic code sits at error.code in the first and at top-level code in the other two.
The third shape is specifically the dialect #7035 retired (PR #7293 converged this file's /meta 501 refusals off it). handleError is the arm that carries the typed service errors the route's own docblock advertises — permission 403, not-found 404, state-conflict 409 — i.e. the arm consumers are most likely to branch on.
Why it is worth recording separately
Under ADR-0112 D5 the destination is fixed: error.code carries the semantic code, the HTTP status lives on the transport. All three arms here predate that convergence, and none of them is wrong individually — they are wrong as a set, which is exactly the class no per-arm review catches.
It is also a behaviour divergence against the runtime dispatcher twin (packages/runtime/src/domains/security.ts), which routes every one of these outcomes through deps.error / deps.errorFromThrown → apiErrorResponse, i.e. one shape: { success: false, error: { code, message, httpStatus } }. That makes it a concrete input to the route-ledger↔live-mount parity gate #7526, and specifically to the argument #7678 makes — that the gate should cover behaviour divergence, not just mount presence. A gate comparing only mount presence is green on all three of these.
Not claimed here
Suggested handling
Triage-level, not decided here: converge the three arms in registerSecurityEndpoints onto the nested { error: { code, message } } shape the same route already uses for its two validation refusals — or fold it into whatever batch is carrying the #7035 convergence through rest-server.ts, since this is the same edit repeated per route family.
Generated by Claude Code
observation-class finding, recorded while fixing #7678 (PR #7979). Not fixed there — that card was scoped to the
?statusvalidation, and this is a separate, wider shape. Filed unassigned.Fact
The three
/api/v1/security/suggested-bindingsroutes inpackages/rest/src/rest-server.ts→registerSecurityEndpointsemit three mutually incompatible error envelopes, depending only on which arm refuses:?status{ error: { code, message } }refuseRepeatedQueryParams/ the new status guard{ code, message }— noerrorwrapper at allrespond501{ code, error: "<string>" }—erroris a bare stringhandleErrorSo a client cannot read one field to learn why its call failed on these routes:
erroris an object in one arm, absent in another, and a human-readable string in the third — and the semantic code sits aterror.codein the first and at top-levelcodein the other two.The third shape is specifically the dialect #7035 retired (PR #7293 converged this file's
/meta501 refusals off it).handleErroris the arm that carries the typed service errors the route's own docblock advertises — permission 403, not-found 404, state-conflict 409 — i.e. the arm consumers are most likely to branch on.Why it is worth recording separately
Under ADR-0112 D5 the destination is fixed:
error.codecarries the semantic code, the HTTP status lives on the transport. All three arms here predate that convergence, and none of them is wrong individually — they are wrong as a set, which is exactly the class no per-arm review catches.It is also a behaviour divergence against the runtime dispatcher twin (
packages/runtime/src/domains/security.ts), which routes every one of these outcomes throughdeps.error/deps.errorFromThrown→apiErrorResponse, i.e. one shape:{ success: false, error: { code, message, httpStatus } }. That makes it a concrete input to the route-ledger↔live-mount parity gate #7526, and specifically to the argument #7678 makes — that the gate should cover behaviour divergence, not just mount presence. A gate comparing only mount presence is green on all three of these.Not claimed here
{ success: false, … }dispatcher wrapper or the REST flat shape is the target: that is the envelope-convergence line (Envelope drift is not just service-storage: four more route modules emit bare bodies, two of them the pre-#3675{ error: '<string>' }#3843 family), not this issue.rest-server.ts里三个相邻/metahandler 的错误信封是三种不同形状,其中两种不符合 ADR-0112 #7035's sweep methodology (grep forcode === '…', not onlycode: '…') applies before any rename.Suggested handling
Triage-level, not decided here: converge the three arms in
registerSecurityEndpointsonto the nested{ error: { code, message } }shape the same route already uses for its two validation refusals — or fold it into whatever batch is carrying the #7035 convergence throughrest-server.ts, since this is the same edit repeated per route family.Generated by Claude Code