Uh oh!
There was an error while loading. Please reload this page.
fix(rest): one error envelope across the /security/explain pair (#8073) - #8174
Conversation
…nvelope (#8073) registerSecurityExplainEndpoints answered two retired dialects across its eight refusal arms: 401/501/400/403 were flat { code, message } and the two 500s were { code, error: 'a bare string' }, so body.error.code -- the one position ADR-0112 D5 declares -- read undefined on all six. The immediately adjacent registrar (#7981, PR #8071) already answered the declared shape, so a client calling explain then suggested-bindings met two envelopes inside one security family. Every arm now emits through one family-local emitter that delegates to the SHARED sendError from @objectstack/types (aliased sendEnvelopeError, as #8135 did, because this module has a local sendError of its own). No status code moves and no code value changes; the 400 arm's Zod dump moves from a top-level `detail` sibling to `error.details`, the slot ApiErrorSchema declares. The three flat-shape pins in security-routes.test.ts are MIGRATED to the D5 position, not deleted, and a driven envelope suite covers all eight arms plus a derived cross-arm shape pin. Ratchet banked: siblingCode 75 -> 73. Measured at merge-base (6ceffe0) 75 and at branch head 73; the two vanished sites are merge-base lines 9332/9390, both inside this function, and every surviving site maps 1:1 by the edit's line shift. stringError is unmoved at 44 -- both 500 arms carried a computed message that counter cannot see. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also reference the affected code. These are read-only:
|
hotlong
commented
Aug 12, 2026
PM review — |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8073
Scoped at triage to the explain pair only —
registerSecurityExplainEndpoints(
GET/POST /api/v1/security/explain,GET /api/v1/security/my-delegable-scope).registerSharingEndpoints/respondSharingErroris deliberately untouched: it issplit out to #8111 and held until its
msg.startsWith(CODE)prefix-protocol questionis ruled.
What was wrong
With #7981 (PR #8071) landed,
registerSecurityEndpointsspeaks the ADR-0112 D5envelope on every arm. Its immediate neighbour carried both dialects that #7035
(PR #7293) had already removed from this file's
/metarefusals:UNAUTHORIZED, 501NOT_IMPLEMENTED, 400VALIDATION_FAILED, 403PERMISSION_DENIED{ code, message }EXPLAIN_FAILED, 500DELEGABLE_SCOPE_FAILED{ code, error: 'a bare string' }So
body.error.code— the one position ADR-0112 D5 declares — readundefinedon allsix, and a client calling
explainand thensuggested-bindingsmet two shapes insideone
securityfamily.The fix
All eight refusal arms of both handlers now go through one family-local emitter that
delegates to the shared
sendErrorfrom@objectstack/types— imported assendEnvelopeError, following #8135, because this module has a localsendErrorof itsown (the sanitizing responder for thrown errors, a different thing). Following PR
#8071's shape, but routed through the shared builder rather than a local body literal, so
the family agrees by construction and
codeis typed to the closed ADR-0112 vocabularyat every call site.
No status code moves, and no code VALUE changes — all six are already registered
(
StandardErrorCodeforNOT_IMPLEMENTED/PERMISSION_DENIED,ERROR_CODE_LEDGER's@objectstack/restblock for the rest), so nothing inpackages/specmoves. The oneother body change: the 400 arm's Zod-issue dump moves from a top-level
detailsiblingto
error.details, the slotApiErrorSchemaactually declares for structured context.Untouched on purpose: the anonymous caller's 401 is a different seam —
enforceAuth's sharedANONYMOUS_DENY_BODY(#2567), which fires before these arms.The pins MIGRATED, none deleted
security-routes.test.ts's three flat-shape pins moved to the D5 position:res.body.code→res.body.error.codeforVALIDATION_FAILED(x2),PERMISSION_DENIED,NOT_IMPLEMENTED,EXPLAIN_FAILED. The 500 pin also gained abody.error.messageassertion, since that arm carried the bare-string dialect too. No pinwas removed and no arm was left on the flat shape.
New driven suite
security-explain-envelope.test.ts(14 cases) covers all eight arms —the delegable-scope handler had no tests at all before — asserting the ADR-0112
pair (status AND nested code, never a bare
toThrow, since these handlers send andnever throw) plus both retired dialects' absence, and a derived cross-arm skeleton pin
so a third dialect fails even if someone also adds a matching literal case.
Reverse verification
Reverted the conversion (
git checkout origin/main -- rest-server.ts, tests left atHEAD): 16 of 21 red, in two distinct predicted directions.
Flat arms —
res.body.errorisundefined:Bare-string 500 arm —
res.body.erroris the string, so.codeisundefined:And the derived skeleton pin caught the divergence structurally:
The 5 that stayed green are the non-envelope cases (route registration, request
delegation, the
enforceAuth401, the healthy 200 decision). Restored:git diff --stat HEADempty (byte-identical), 21/21 green.SDK claim RE-MEASURED against these consumers
Not inherited from #7981. Drove the real
ObjectStackClientagainst a stubbed transportanswering the old and new body for each of the eight arms, through
client.security.explain()andclient.security.describeDelegableScope():err.codeerr.messageerr.httpStatus/err.category/err.retryable/err.fieldserr.detailsObjectStackClient.fetchreads both envelopes' declared spots (errorBody?.code ?? errorBody?.error?.code, and a bare-string limb for the message), which is why the twothat matter do not move.
err.detailsdoes change, on every arm, because its lastfallback is the whole response body and the whole body is what changed; on the 400 arm
it now resolves to the declared
error.detailsinstead. No consumer in the repo readserr.detailsfor these routes,content/docs/permissions/*documents no error body forthem, and the other in-repo
security.explaincallers (runtime/src/domains/automation.ts,the dogfood suites) call the service, not the route.
Envelope ratchet LOWERED, per the gate's own rule
siblingCode 75 → 73inscripts/check-route-envelope.mjs. Measured at merge-base(
6ceffe0ac) 75 and at branch head 73; the two vanished sites are merge-base lines9332/9390 — both the 500 arms inside this function — and every surviving site maps 1:1
onto a head line by the edit's own line shift (verified programmatically: zero unmatched,
zero new).
stringErroris unmoved at 44 by construction: both 500 arms carried acomputed message (
msg.slice(0, 500)) that counter cannot see, and the six flat armswere never counted by either dialect, having no
errorkey at all.Verification
pnpm --filter @objectstack/rest test— 103 files, 1741 tests, all passingpnpm --filter @objectstack/rest typecheck— cleanpnpm check:type-check-debt(full closure built first, aslint.ymldoes) — OK, "noneabove its recorded number";
@objectstack/reststays at its recorded 155, not raisedpnpm check:route-envelopeincl.--self-test— green with the banked 73node scripts/check-nul-bytes.mjs— OK, plus a self-scan of every changed file for thewider control-byte range
Generated by Claude Code