Skip to content

Commit db8c288

Browse files
os-litantclaude
andauthored
fix(types): let sendError's extra carry declaredCode (#12403)
`ApiErrorSchema` has declared `declaredCode` since the ADR-0112 amendment (#9106) and the flat `/data` door emits it, but `sendError`'s `extra` was typed `Pick<ApiError, 'category' | 'httpStatus' | 'details' | 'requestId'>` — so passing a demoted producer spelling on a nested-envelope route was a compile error, and the author's own code was dropped while the derived closed member shipped in its place. Additive: `declaredCode` joins the `Pick`. No call site changes and no wire byte moves for any body already emitted; the contract's accept set is untouched, since the schema always permitted the field. Presence still means demotion, and the writer does not re-derive that — the caller passes `demotedDeclaredCode(thrown)`, exactly as the flat door's `thrownCodeFields` does. Pinned by driving the real resolver pipeline and parsing the emitted body with the real `ApiErrorSchema`, asserting the field survives the parse (a plain `z.object` strips undeclared keys, so `.success` alone would pass against a schema declaring nothing). Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7b2f941 commit db8c288

3 files changed

Lines changed: 186 additions & 3 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/types": minor
3+
---
4+
5+
fix(types): let `sendError`'s `extra` carry `declaredCode`, so a nested-envelope route can emit the ADR-0112 open channel (#11719)
6+
7+
`ApiErrorSchema` has declared `declaredCode` since #9106 — the open,
8+
author-authored channel that carries a metadata app's own `.code` verbatim when
9+
the spelling is not a member of the closed `code` vocabulary. ADR-0112's
10+
2026-08-17 amendment rules that demote **platform-wide**, and #9232 extended it
11+
to the flat `/data` door, which emits the pair today.
12+
13+
The shared nested-envelope writer could not. `sendError`'s `extra` was typed
14+
`Pick<ApiError, 'category' | 'httpStatus' | 'details' | 'requestId'>`, so
15+
passing a demoted spelling was a **compile error** and every route answering the
16+
nested envelope dropped it. Nothing invalid shipped — the closed `code` still
17+
carried the member derived from the status — which is exactly what made the loss
18+
silent and one-directional: the author's spelling gone, and a consumer told by
19+
the ADR to read `declaredCode` finding nothing there. Declared-but-unemittable
20+
is a `declared = enforced` gap, closed here at the one writer rather than per
21+
module.
22+
23+
Additive: `declaredCode` joins the `Pick`. No existing call site changes, no
24+
wire byte moves for any body already being emitted, and the contract's accept
25+
set is untouched — the schema has always permitted the field.
26+
27+
⛔ Presence still MEANS demotion, and the writer does not re-derive that. The
28+
caller passes `demotedDeclaredCode(thrown)` (`@objectstack/types`), exactly as
29+
the flat door's `thrownCodeFields` does; that helper answers `undefined` when
30+
the producer's spelling is already the vocabulary member sitting in `code`, so a
31+
registered refusal never carries two spellings of one fact. Vocabulary and
32+
position stay two decisions (#9232).
33+
34+
Pinned in `response-envelope.test.ts` by driving the real pipeline — a
35+
sandbox-shaped throw carrying a tenant-authored `.code` through
36+
`resolveThrownHttpError` and `demotedDeclaredCode` — and by parsing the emitted
37+
body with the real `ApiErrorSchema`, asserting the field is still on it *after*
38+
the parse. `ApiErrorSchema` is a plain `z.object` that strips undeclared keys,
39+
so a `.success` assertion alone would have passed against a schema declaring
40+
nothing.

packages/types/src/response-envelope.test.ts

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@
2323
*/
2424

2525
import { describe, it, expect } from 'vitest';
26-
import { BaseResponseSchema, envelopeViolations } from '@objectstack/spec/api';
26+
import { ApiErrorSchema, BaseResponseSchema, envelopeViolations } from '@objectstack/spec/api';
2727
import { sendOk, sendError, type EnvelopeResponse } from './response-envelope.js';
28+
import { resolveThrownHttpError, demotedDeclaredCode } from './thrown-http-error.js';
2829

2930
/** Captures what a route would have put on the wire. */
3031
function capture() {
@@ -133,3 +134,114 @@ describe('sendError', () => {
133134
sendError(res, 400, 'NOT_A_REGISTERED_CODE', 'invented');
134135
});
135136
});
137+
138+
/**
139+
* The ADR-0112 open channel on the NESTED envelope (#9106, #9232).
140+
*
141+
* `ApiErrorSchema` has declared `declaredCode` since #9106 and the flat `/data`
142+
* door emits it, but `sendError`'s `extra` did not admit the field — so putting
143+
* a demoted producer spelling on a nested-envelope route was a COMPILE ERROR,
144+
* and the author's own code was dropped while the derived closed member shipped
145+
* in its place. Nothing invalid went on the wire, which is exactly what made the
146+
* loss silent.
147+
*
148+
* These drive the REAL pipeline rather than asserting the type: a thrown error
149+
* shaped like a sandboxed hook's refusal goes through `resolveThrownHttpError`
150+
* and `demotedDeclaredCode` — the one rule both doors read — and the body is
151+
* parsed by the real `ApiErrorSchema`. A type-level assertion would have passed
152+
* against a schema that declares nothing.
153+
*/
154+
describe('sendError — the `declaredCode` open channel', () => {
155+
/**
156+
* The reachable producer ADR-0112's amendment names: a metadata app's own
157+
* `.code` crossing the QuickJS boundary (#7867) on a hook refusal. It is
158+
* NOT a member of `StandardErrorCode ∪ ERROR_CODE_LEDGER`, so the closed
159+
* slot cannot hold it.
160+
*/
161+
const tenantAuthored = () => Object.assign(
162+
new Error('Quota exceeded for this app.'),
163+
{ code: 'crm.quota_exceeded', status: 403 },
164+
);
165+
166+
it('carries the producer spelling beside the derived closed member', () => {
167+
const { res, seen } = capture();
168+
const thrown = resolveThrownHttpError(tenantAuthored());
169+
const demoted = demotedDeclaredCode(thrown);
170+
171+
sendError(res, thrown.status, thrown.code, thrown.message, {
172+
...(demoted !== undefined ? { declaredCode: demoted } : {}),
173+
});
174+
175+
expect(seen.status).toBe(403);
176+
expect(seen.body).toEqual({
177+
success: false,
178+
error: {
179+
// Derived from the status, because the spelling is unregistered.
180+
code: 'PERMISSION_DENIED',
181+
message: 'Quota exceeded for this app.',
182+
// …and the author's own spelling survives, verbatim.
183+
declaredCode: 'crm.quota_exceeded',
184+
},
185+
});
186+
});
187+
188+
it('emits a body the REAL schemas accept, with the field still on it', () => {
189+
// `.success` alone would not have caught this: `ApiErrorSchema` is a
190+
// plain `z.object`, so an UNDECLARED sibling parses clean by being
191+
// STRIPPED. The reading that means something is that the field is still
192+
// there AFTER the parse — i.e. the schema declares it.
193+
const { res, seen } = capture();
194+
const thrown = resolveThrownHttpError(tenantAuthored());
195+
sendError(res, thrown.status, thrown.code, thrown.message, {
196+
declaredCode: demotedDeclaredCode(thrown)!,
197+
});
198+
199+
const body = seen.body as { error: unknown };
200+
expect(BaseResponseSchema.safeParse(seen.body).success).toBe(true);
201+
expect(envelopeViolations(seen.body)).toEqual([]);
202+
203+
const parsed = ApiErrorSchema.safeParse(body.error);
204+
expect(parsed.success).toBe(true);
205+
expect((parsed as { data: { declaredCode?: string } }).data.declaredCode)
206+
.toBe('crm.quota_exceeded');
207+
});
208+
209+
it('the survives-the-parse reading can say NO — an undeclared sibling is stripped', () => {
210+
// The control for the assertion above, on a term that is not a
211+
// substring of the one under test. `ApiErrorSchema` strips rather than
212+
// rejects, so "parsed clean" is worthless on its own; this pins that the
213+
// instrument distinguishes a DECLARED field from a tolerated one.
214+
const parsed = ApiErrorSchema.safeParse({
215+
code: 'PERMISSION_DENIED',
216+
message: 'denied',
217+
declaredCode: 'crm.quota_exceeded',
218+
namespace: 'branding',
219+
});
220+
221+
expect(parsed.success).toBe(true);
222+
const data = (parsed as { data: Record<string, unknown> }).data;
223+
expect(data.declaredCode).toBe('crm.quota_exceeded');
224+
expect('namespace' in data).toBe(false);
225+
});
226+
227+
it('stays ABSENT when the producer spelled a registered code', () => {
228+
// `ApiErrorSchema.declaredCode`'s documented invariant: presence MEANS
229+
// demotion. A registered spelling is already in `code`, and repeating it
230+
// would make one refusal carry two spellings of one fact. The writer does
231+
// not re-derive that — `demotedDeclaredCode` does, for both doors.
232+
const { res, seen } = capture();
233+
const thrown = resolveThrownHttpError(
234+
Object.assign(new Error('denied'), { code: 'PERMISSION_DENIED', status: 403 }),
235+
);
236+
const demoted = demotedDeclaredCode(thrown);
237+
238+
expect(demoted).toBeUndefined();
239+
240+
sendError(res, thrown.status, thrown.code, thrown.message, {
241+
...(demoted !== undefined ? { declaredCode: demoted } : {}),
242+
});
243+
244+
expect(Object.keys((seen.body as { error: object }).error))
245+
.toEqual(['code', 'message']);
246+
});
247+
});

packages/types/src/response-envelope.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,8 @@ export function sendOk(res: EnvelopeResponse, data: unknown, status = 200): void
110110
* ## `extra` is `ApiError`'s own optional fields, not a `Record`
111111
*
112112
* Merged into `error`, and typed as exactly what `ApiErrorSchema` declares
113-
* beside `code` and `message` — `details`, `category`, `requestId`, `httpStatus`.
113+
* beside `code` and `message` — `details`, `category`, `requestId`,
114+
* `httpStatus`, `declaredCode`.
114115
* `details` is the slot for structured context: `package-routes` puts a partial
115116
* delete's per-item failures there, `settings-routes` the whole
116117
* `SettingsActionResult`.
@@ -126,13 +127,43 @@ export function sendOk(res: EnvelopeResponse, data: unknown, status = 200): void
126127
* Closing it at the shared builder is the part that lasts: an undeclared sibling
127128
* is now a compile error in every module at once, rather than a key that quietly
128129
* evaporates at the schema boundary in whichever module reintroduces it.
130+
*
131+
* ## `declaredCode` — declared by the schema, barred by this writer
132+
*
133+
* ADR-0112's 2026-08-17 amendment (#9106, extended to the flat `/data` door by
134+
* #9232) rules the demote at EVERY door: `code` stays the closed vocabulary,
135+
* and a thrown code that is not a member is demoted to a declared sibling,
136+
* `ApiError.declaredCode` — the open, author-authored channel that carries a
137+
* metadata app's OWN `.code` across the QuickJS boundary (#7867) and onto the
138+
* wire.
139+
*
140+
* `ApiErrorSchema` has declared that field since #9106 and the flat door emits
141+
* it, but it was absent from the `Pick` above — so it was a COMPILE ERROR for
142+
* any route answering the NESTED envelope to pass one, and every such route
143+
* dropped the producer's spelling. Nothing invalid shipped (the closed `code`
144+
* still carried the derived member), which is what made the loss silent and
145+
* one-directional: the author's spelling gone, and a consumer told by the ADR
146+
* to read `declaredCode` finding nothing there. Declared-but-unemittable is a
147+
* `declared = enforced` gap, and admitting the field closes it at the ONE
148+
* writer rather than in each module that later notices.
149+
*
150+
* ⛔ Presence MEANS demotion, and this writer does not re-derive that — the
151+
* CALLER does, with `demotedDeclaredCode` (`thrown-http-error.ts`, one file
152+
* over), exactly as the flat door's `thrownCodeFields` already does. That
153+
* helper answers `undefined` when the producer's spelling IS the vocabulary
154+
* member already sitting in `code`, which is what stops a registered refusal
155+
* from carrying two spellings of one fact — `ApiErrorSchema.declaredCode`'s
156+
* documented invariant. Passing a raw `thrown.declaredCode` re-opens exactly
157+
* that, and no type here can catch it: vocabulary and position stay two
158+
* decisions (#9232), so the demotion rule stays with the resolver that owns
159+
* it rather than being restated in the envelope writer.
129160
*/
130161
export function sendError(
131162
res: EnvelopeResponse,
132163
status: number,
133164
code: ErrorCode,
134165
message: string,
135-
extra?: Pick<ApiError, 'category' | 'httpStatus' | 'details' | 'requestId'>,
166+
extra?: Pick<ApiError, 'category' | 'httpStatus' | 'details' | 'requestId' | 'declaredCode'>,
136167
): void {
137168
res.status(status).json({ success: false, error: { code, message, ...extra } });
138169
}

0 commit comments

Comments
 (0)