From f2063854aeadd58e8112e89a65cdbe5580b88203 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 05:51:40 +0000 Subject: [PATCH] fix(rest): the record-share family carries a demoted producer code on `declaredCode` (#12510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `respondSharingError` asks `classifiedRefusalAnswer` — the flat `/data` door's own classification, which already holds the demoted producer spelling — and re-dressed only `status`, `code` and the message into the nested ADR-0112 D5 envelope. The author's own code was dropped one line below the local that carried it: nothing invalid shipped, since the closed `code` still carried the member the status derives, which is what made the loss silent and one-directional. The pair is carried, not recomputed. `refusal.body.declaredCode` is the shared rule's OUTPUT (`thrownCodeFields` = `resolveThrownHttpError` + `demotedDeclaredCode`), so "presence means demotion" arrives with the value; the resolver's RAW `declaredCode` is deliberately not read, because it is set for a REGISTERED spelling too and forwarding it would put two spellings of one fact on every registered refusal. The comment that explained the omission is repaired in the same change. It said `sendError`'s `extra` would not accept the field — true when written, false since `db8c288` (PR #12403) added `declaredCode` to the writer's `Pick`. Left alone it reads as "blocked upstream, nothing to do here" to the next author, which is the direction that costs a repair rather than an understanding. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd --- .changeset/share-door-declared-code.md | 40 ++ packages/rest/src/rest-server.ts | 65 ++- .../rest/src/rest-share-declared-code.test.ts | 455 ++++++++++++++++++ 3 files changed, 551 insertions(+), 9 deletions(-) create mode 100644 .changeset/share-door-declared-code.md create mode 100644 packages/rest/src/rest-share-declared-code.test.ts diff --git a/.changeset/share-door-declared-code.md b/.changeset/share-door-declared-code.md new file mode 100644 index 0000000000..d26453c8f0 --- /dev/null +++ b/.changeset/share-door-declared-code.md @@ -0,0 +1,40 @@ +--- +"@objectstack/rest": patch +--- + +fix(rest): the record-share family carries a demoted producer code on `declaredCode` (#12510) + +`GET`/`POST /api/v1/data/:object/:id/shares` and `DELETE …/shares/:shareId` now +put a producer's own error-code spelling on the wire's `error.declaredCode` when +the closed ADR-0112 vocabulary did not admit it. Previously that spelling was +resolved and then dropped at the re-dress: `respondSharingError` +(`packages/rest/src/rest-server.ts`) asks `classifiedRefusalAnswer` — the flat +`/data` door's own classification, which already carries the demoted string — +and forwarded only `status`, `code` and the message into the nested ADR-0112 D5 +envelope. + +Nothing invalid shipped, which is what made the loss silent and one-directional: +the closed `code` still carried the member the HTTP status derives, so every +body parsed, while an author's spelling vanished and a consumer told by ADR-0112 +to read `declaredCode` found nothing there. Measured before the repair, one +producer through both doors: a thrown `{ code: 'CLOSE_PERIOD_LOCKED', status: +409 }` answered `409 RESOURCE_CONFLICT` at both, with `declaredCode: +'CLOSE_PERIOD_LOCKED'` at `/data` and nothing at the share door. + +This ADOPTS the rule the sibling doors already apply rather than inventing one. +The demote is `demotedDeclaredCode`'s answer — the single definition of +"presence means demotion" — reached here through the classification's own +`declaredCode`, which the flat door computes with exactly that function +(`thrownCodeFields`, `packages/rest/src/error-response.ts`, #9232). The pair is +carried, not recomputed: this door asks the classification once and re-dresses +that one answer, as it already does for `status`, `code` and the message. + +Additive and shape-preserving. A REGISTERED producer code still carries no +`declaredCode` (repeating it would put two spellings of one fact on every +refusal), a producer that declared no code still carries none, a non-string +`code` is still context rather than a wire spelling, and `status`, `code` and +`message` are byte-identical to before on every existing path. The five-prefix +ADR-0111 idiom and the family's own `SHARES_LIST_FAILED` / +`SHARE_GRANT_FAILED` / `SHARE_REVOKE_FAILED` 500 terminal are untouched. +`ApiErrorSchema` has declared the field as optional since #9106, so the +contract's accept set does not move. diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 283e966e07..1d1ab66049 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -9581,7 +9581,14 @@ export class RestServer { status: number, code: ErrorCode, message: string, - ): void => sendEnvelopeError(res, status, code, message); + // [#12510] The shared writer's OWN `extra` type, referenced rather + // than restated: this wrapper decides POSITION (the nested D5 + // envelope), never which channels exist. A local `{ declaredCode?: + // string }` would be a second, narrower declaration of a set + // `sendError` already owns — the shape that silently stops + // forwarding the next channel admitted there. + extra?: Parameters[4], + ): void => sendEnvelopeError(res, status, code, message, extra); const respond501 = (res: any) => respondError( res, 501, 'NOT_IMPLEMENTED', @@ -9651,17 +9658,57 @@ export class RestServer { // is the one place the two dialects genuinely differ: the flat // body may omit `code`, the nested one may not. // - // ⚠️ Measured and NOT repaired here: an UNREGISTERED producer - // code is demoted by the shared resolver to a `declaredCode` - // sibling (ADR-0112 #9232), and `sendError`'s `extra` does not - // accept that field — so the author's own spelling is dropped - // on this family while `/data` carries it. Widening the shared - // envelope writer is a `@objectstack/types` change outside - // this card's surface; filed separately. + // [#12510] …and the producer's OWN spelling travels with it. + // An UNREGISTERED thrown code is demoted by the shared rule to + // a `declaredCode` sibling (ADR-0112 #9232) — the open, + // author-authored channel `ApiErrorSchema` has declared since + // #9106. This family used to drop it: the classification below + // was already holding the demoted string and only `code` and + // the message were re-dressed, so an app's spelling vanished + // here while the flat `/data` door carried it. Nothing invalid + // shipped — the closed `code` still carried the member the + // status derives — which is exactly what made the loss silent + // and one-directional: a consumer told by ADR-0112 to read + // `declaredCode` found nothing at this door. + // + // ⛔ The reason that used to stand here said `sendError`'s + // `extra` would not accept the field. That was true when it was + // written and false since #11719 / `db8c288` (PR #12403) added + // `declaredCode` to the writer's `Pick`. A stale sentence that + // discourages a repair costs more than one that misdescribes a + // mechanism, so it is recorded rather than merely deleted. + // + // ⛔ Read the CLASSIFICATION's field, never the resolver's raw + // `thrown.declaredCode`. Presence MEANS demotion + // (`ApiErrorSchema.declaredCode`'s documented invariant) and + // the raw field is set for a REGISTERED spelling too — + // measured: a producer throwing `{ code: 'RECORD_LOCKED', + // status: 409 }` resolves with `declaredCode: 'RECORD_LOCKED'` + // sitting beside an identical `code`, and forwarding that would + // put two spellings of one fact on every registered refusal. + // `refusal.body.declaredCode` is the answer AFTER + // `demotedDeclaredCode` (`error-response.ts`'s + // `thrownCodeFields`, the same one definition the dispatcher + // door reads), so the invariant arrives with the value. + // + // ⭐ Why re-dress rather than re-resolve: this door asks + // {@link classifiedRefusalAnswer} ONCE and re-dresses that one + // answer, exactly as it does for `status`, `code` and the + // message. Calling the resolver a second time here would be a + // second answer to a question already asked — the shape that + // let two `/api/v1/packages` doors drift apart (#12405). The + // pair is carried, not recomputed: `code` and `declaredCode` + // leave this door as the pair `thrownCodeFields` produced. const code = typeof refusal.body.code === 'string' ? refusal.body.code as ErrorCode : standardErrorCodeForHttpStatus(refusal.status); - respondError(res, refusal.status, code, String(refusal.body.error ?? '')); + const declaredCode = typeof refusal.body.declaredCode === 'string' + ? refusal.body.declaredCode + : undefined; + respondError( + res, refusal.status, code, String(refusal.body.error ?? ''), + declaredCode !== undefined ? { declaredCode } : undefined, + ); return true; } const msg = String(error?.message ?? error ?? ''); diff --git a/packages/rest/src/rest-share-declared-code.test.ts b/packages/rest/src/rest-share-declared-code.test.ts new file mode 100644 index 0000000000..27d2ef9606 --- /dev/null +++ b/packages/rest/src/rest-share-declared-code.test.ts @@ -0,0 +1,455 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#12510] The record-share family carries a DEMOTED producer spelling on the + * nested envelope's `declaredCode`, instead of dropping the one it was already + * holding. + * + * ## The defect, reproduced before it was repaired + * + * `respondSharingError` (`rest-server.ts`) asks {@link classifiedRefusalAnswer} + * — the `/data` door's own classification — and then re-dressed only `status`, + * `code` and the message into the nested ADR-0112 D5 envelope (#8111). The + * classification's body was ALREADY carrying the demoted spelling, computed by + * the one shared rule (`thrownCodeFields` → `demotedDeclaredCode`), and it was + * dropped at the re-dress. + * + * Measured on `9a2f3dfe4` before the repair, one producer through both doors: + * + * ```text + * throw { code: 'CLOSE_PERIOD_LOCKED', status: 409 } + * share door : 409 {"error":{"code":"RESOURCE_CONFLICT","message":"invoices still open"}} + * /data door : 409 {"error":"invoices still open","code":"RESOURCE_CONFLICT", + * "declaredCode":"CLOSE_PERIOD_LOCKED"} + * ``` + * + * Nothing invalid shipped — the closed `code` still carried the member the + * status derives — which is what made the loss silent and one-directional: the + * author's spelling gone, and a consumer told by ADR-0112 to read + * `declaredCode` finding nothing at this door while the twin carried it. + * + * ## Why the repair reads the CLASSIFICATION and not a second resolver call + * + * The near-twin at the direct-mount package registrar (#12405, PR #12508) holds + * a `ThrownHttpError` in hand and passes `demotedDeclaredCode(thrown)`. This + * call site does not hold one: it holds `classifiedRefusalAnswer`'s + * `{ status, body }`, the flat answer whose `code` and message it is already + * re-dressing. `body.declaredCode` is that same shared rule's OUTPUT — the flat + * door's `thrownCodeFields` is literally `resolveThrownHttpError(error, status)` + * followed by `demotedDeclaredCode` — so carrying it forwards the pair the rule + * produced rather than asking the rule a second time at the re-dress. A second + * call would be a second answer to a question this door deliberately asks once, + * which is the shape that let the two `/api/v1/packages` doors drift apart in + * the first place. §5 pins the equivalence from the outside so either side + * drifting goes red. + * + * ⛔ What must NOT be read here is the resolver's RAW `thrown.declaredCode`. + * Measured: a producer throwing `{ code: 'RECORD_LOCKED', status: 409 }` + * resolves to `code: 'RECORD_LOCKED'` with `declaredCode: 'RECORD_LOCKED'` + * beside it — the raw field records what the producer WROTE, not what was + * demoted. Forwarding it would put two spellings of one fact on every + * registered refusal, which is exactly the invariant `ApiErrorSchema` + * documents for this field. §2 asserts the absence directly, on codes that ARE + * ledger members. + * + * ## What is deliberately NOT asserted + * + * That the body merely "has one more field" or that `declaredCode` is + * "defined". Both pass for a door that stamps the raw spelling on every + * refusal. Every positive case below names the exact string, and §2 pins the + * absence. + * + * ## Predicted before running the ablation (deleting the repair's two lines) + * + * §1 RED — the four demoting shapes lose the spelling again, x3 routes + * §2 GREEN — the absences an unrepaired door satisfies too + * §3 RED — for the demoting shapes only; the non-demoting rows stay green + * §4 GREEN — the envelope, the prefix idiom and the 500 terminal never moved + * §5 RED — for the demoting shapes only, same reason as §3 + */ + +import { describe, it, expect, vi } from 'vitest'; +// `.js` on purpose — this package resolves `nodenext`, so an extensionless +// relative import is a `tsc` error (TS2835). +import { RestServer } from './rest-server.js'; +import { handleRouteError, classifiedRefusalAnswer } from './error-response.js'; +import { resolveThrownHttpError, demotedDeclaredCode } from '@objectstack/types'; +import { ApiErrorSchema } from '@objectstack/spec/api'; + +const LIST = '/api/v1/data/:object/:id/shares'; +const REVOKE = '/api/v1/data/:object/:id/shares/:shareId'; + +type Answer = { status: number; body: any }; + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +function mockRes() { + const res: any = { + statusCode: 200, + json: vi.fn(function (this: any, body: any) { this._body = body; return this; }), + send: vi.fn(function (this: any) { return this; }), + end: vi.fn(function (this: any) { return this; }), + setHeader: vi.fn(function (this: any) { return this; }), + status: vi.fn(function (this: any, code: number) { this.statusCode = code; return this; }), + header: vi.fn(function (this: any) { return this; }), + }; + return res; +} + +/** + * The shape `runtime/src/sandbox/quickjs-runner.ts` produces: `.message` is the + * ` '' threw: ` debug wrapper and `.innerMessage` the business + * text. Reproduced here so `@objectstack/rest` does not depend on + * `@objectstack/runtime` to run its own tests — the same fixture + * `rest-share-refusal-classification.test.ts` uses. + */ +function sandboxRefusal(businessMessage: string, extra: Record = {}) { + const err: any = new Error(`hook 'guard' threw: Error: ${businessMessage}`); + err.name = 'SandboxError'; + err.innerMessage = businessMessage; + return Object.assign(err, extra); +} + +/** A producer's throw, carrying whatever it declares. */ +function refusal(message: string, carried: Record) { + return Object.assign(new Error(message), carried); +} + +/** + * The three catch sites this family has, each driven through the REAL route + * with a service verb that rejects — plus a witness that the verb was actually + * called, so a case that silently never reached the seam cannot "pass" on a + * body it got for some entirely different reason. + */ +function boot(err: unknown) { + const service = { + listShares: vi.fn().mockRejectedValue(err), + grant: vi.fn().mockRejectedValue(err), + revoke: vi.fn().mockRejectedValue(err), + }; + const rest = new RestServer( + mockServer() as any, + { getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: {} }) } as any, + { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, + undefined, + (async () => service) as any, + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'u_admin' }); + rest.registerRoutes(); + + const drive = async (method: string, path: string): Promise => { + const found = (rest as any).getRoutes().find( + (r: any) => r.method === method && r.path === path, + ); + if (!found) throw new Error(`route not registered: ${method} ${path}`); + const res = mockRes(); + await found.handler( + { + method, path, headers: {}, query: {}, body: {}, + params: { object: 'account', id: 'a1', shareId: 'shr_X' }, + } as any, + res, + ); + const calls = res.json.mock.calls; + return { status: res.statusCode, body: calls[calls.length - 1]?.[0] }; + }; + + return { service, drive }; +} + +/** All three routes, with the seam-reached witness for each. */ +async function allThree(err: unknown): Promise> { + const { service, drive } = boot(err); + const list = await drive('GET', LIST); + expect(service.listShares.mock.calls.length, 'GET never reached listShares').toBe(1); + const grant = await drive('POST', LIST); + expect(service.grant.mock.calls.length, 'POST never reached grant').toBe(1); + const revoke = await drive('DELETE', REVOKE); + expect(service.revoke.mock.calls.length, 'DELETE never reached revoke').toBe(1); + return [['GET shares', list], ['POST shares', grant], ['DELETE shares/:shareId', revoke]]; +} + +/** The wire answer the flat `/data` door gives for the same error. */ +function throughDataDoor(error: any): Answer { + const res = mockRes(); + handleRouteError(res, error); + const calls = res.json.mock.calls; + return { status: res.statusCode, body: calls[calls.length - 1]?.[0] }; +} + +/** + * The ADR-0112 D5 pair at the NESTED position, asserted beside every claim + * below so a repair that moved this family back to the flat dialect — or that + * put `declaredCode` at the body's top level — cannot pass here. + */ +function expectNestedEnvelope(answer: Answer, status: number, code: string): any { + expect( + answer.status, + `expected ${status}, got ${answer.status} with body ${JSON.stringify(answer.body)}`, + ).toBe(status); + expect(answer.body?.error?.code).toBe(code); + expect(typeof answer.body?.error?.message).toBe('string'); + expect(answer.body).not.toHaveProperty('code'); + expect(answer.body).not.toHaveProperty('declaredCode'); + const parsed = ApiErrorSchema.safeParse(answer.body?.error); + expect(parsed.error?.issues ?? []).toEqual([]); + expect(parsed.success).toBe(true); + return answer.body.error; +} + +// ───────────────────────────────────────────────────────────────────────────── +// §1 The demote reaches the wire, on all three routes +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Both status spellings are produced in this repo (`metadata-protocol` throws + * `status`, the lifecycle hooks throw `statusCode`, #7525), and the sandbox + * limb is the OTHER way this family classifies a refusal — a metadata app's + * own `.code` crossing the QuickJS boundary (#7867) is precisely the + * population `declaredCode` exists for, and no ledger can enumerate it. + */ +const DEMOTED: Array<{ + name: string; error: unknown; status: number; code: string; declaredCode: string; +}> = [ + { + name: 'an app spelling on a declared 409 (`status`)', + error: refusal('invoices still open', { code: 'CLOSE_PERIOD_LOCKED', status: 409 }), + status: 409, code: 'RESOURCE_CONFLICT', declaredCode: 'CLOSE_PERIOD_LOCKED', + }, + { + name: 'an app spelling on a declared 403 (`statusCode`, #7525)', + error: refusal('seat count exceeded', { code: 'ORG_LICENCE_INVALID', statusCode: 403 }), + status: 403, code: 'PERMISSION_DENIED', declaredCode: 'ORG_LICENCE_INVALID', + }, + { + name: 'a sandboxed hook body that declared an app spelling and a 403', + error: sandboxRefusal('only the record owner may re-share this account', { + code: 'ONLY_OWNER_MAY_RESHARE', status: 403, + }), + status: 403, code: 'PERMISSION_DENIED', declaredCode: 'ONLY_OWNER_MAY_RESHARE', + }, + { + name: 'a sandboxed hook body with an app spelling and NO declared status', + error: sandboxRefusal('only the record owner may re-share this account', { + code: 'ONLY_OWNER_MAY_RESHARE', + }), + status: 400, code: 'VALIDATION_ERROR', declaredCode: 'ONLY_OWNER_MAY_RESHARE', + }, +]; + +describe('[#12510] an UNREGISTERED producer spelling rides `declaredCode` on the share family', () => { + for (const demoted of DEMOTED) { + it(`${demoted.name}`, async () => { + for (const [route, answer] of await allThree(demoted.error)) { + const error = expectNestedEnvelope(answer, demoted.status, demoted.code); + // ADR-0112, all three channels at once: `code` stays the CLOSED + // member the status derives — the demote must not re-open the + // vocabulary — and the producer's own string arrives BESIDE it + // rather than instead of it. + expect(error.declaredCode, `${route}: ${JSON.stringify(answer.body)}`) + .toBe(demoted.declaredCode); + } + }); + } + + it('the four shapes above really do produce different answers', () => { + // Anti-vacuity for the table itself: four rows collapsing to one answer + // would agree with almost any implementation. + const answers = DEMOTED.map((d) => `${d.status} ${d.code} ${d.declaredCode}`); + expect(new Set(answers).size).toBe(4); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// §2 Presence MEANS demotion — the half a "field is defined" assertion misses +// ───────────────────────────────────────────────────────────────────────────── + +const ABSENT: Array<{ name: string; error: unknown; status: number; code: string }> = [ + { + // ⭐ THE trap case. `resolveThrownHttpError` answers this throw with + // `declaredCode: 'RECORD_LOCKED'` sitting beside an identical `code` — + // a door reading the raw field would emit both spellings here. + name: 'a REGISTERED code is already in `code`; repeating it would be two spellings of one fact', + error: refusal('this account is locked while month-end close runs', { + code: 'RECORD_LOCKED', status: 409, + }), + status: 409, code: 'RECORD_LOCKED', + }, + { + // `plugin-sharing`'s own write gate throws exactly this (`sharing-plugin.ts`). + name: "plugin-sharing's own FORBIDDEN write-gate refusal — the live in-repo producer", + error: refusal('FORBIDDEN: insufficient privileges to delete account a1', { + code: 'FORBIDDEN', status: 403, + }), + status: 403, code: 'FORBIDDEN', + }, + { + name: 'a sandboxed body that declared NO code has nothing to declare', + error: sandboxRefusal('sharing is frozen until the quarterly access review closes'), + status: 400, code: 'VALIDATION_ERROR', + }, + { + name: "the ADR-0111 `CODE: message` prefix idiom declares no envelope at all", + error: new Error('NOT_FOUND: record account/a1 does not exist'), + status: 404, code: 'NOT_FOUND', + }, +]; + +describe('[#12510] `declaredCode` is ABSENT unless the demote actually happened', () => { + for (const absent of ABSENT) { + it(`${absent.name}`, async () => { + for (const [route, answer] of await allThree(absent.error)) { + const error = expectNestedEnvelope(answer, absent.status, absent.code); + // Absent, not `undefined`-valued: a key present with `undefined` + // survives `JSON.stringify` as an omission but would not through + // every transport, and "presence means demotion" is a statement + // about the KEY. + expect( + 'declaredCode' in error, + `${route} emitted ${JSON.stringify(answer.body)}`, + ).toBe(false); + } + }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// §3 Door-to-door — this family and `/data` answer one producer alike +// ───────────────────────────────────────────────────────────────────────────── + +describe('[#12510] the share door and the flat `/data` door agree on the producer spelling', () => { + it('every classified refusal carries the same `declaredCode` at both doors', async () => { + // This is the reproduction turned into a pin: the measurement that + // opened the card was precisely a DISAGREEMENT between these two doors + // about one producer's spelling, with the flat one right. + for (const { name, error } of [...DEMOTED, ...ABSENT]) { + const flat = throughDataDoor(error); + const expected = flat.body?.declaredCode; + for (const [route, answer] of await allThree(error)) { + expect( + answer.body?.error?.declaredCode, + `${name} @ ${route}: share door ${JSON.stringify(answer.body)} vs ` + + `/data door ${JSON.stringify(flat.body)}`, + ).toBe(expected); + } + } + }); + + it('the comparison is not vacuous — the flat door really does answer both ways', () => { + // Without this, a `/data` door that emitted nothing at all would make + // every row above compare `undefined` to `undefined`. + const carried = DEMOTED + .map((d) => throughDataDoor(d.error).body?.declaredCode) + .filter((c) => typeof c === 'string'); + const bare = ABSENT + .map((a) => throughDataDoor(a.error).body?.declaredCode) + .filter((c) => c === undefined); + expect(carried.length).toBe(DEMOTED.length); + expect(bare.length).toBe(ABSENT.length); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// §4 What must NOT move — the message, the dialect, and the 500 terminal +// ───────────────────────────────────────────────────────────────────────────── + +describe('[#12510] the channel added displaces nothing that was already there', () => { + it('the message and the closed `code` are byte-identical to the pre-repair answers', async () => { + // The three channels this family already published, restated as + // literals so a repair that "carried" the spelling by overwriting one + // of them cannot pass. + for (const [route, answer] of await allThree( + refusal('invoices still open', { code: 'CLOSE_PERIOD_LOCKED', status: 409 }), + )) { + expect(answer.status, route).toBe(409); + expect(answer.body.error.code, route).toBe('RESOURCE_CONFLICT'); + expect(answer.body.error.message, route).toBe('invoices still open'); + } + }); + + it('an unclassified fault still leaves through this family\'s own 500, unchanged', async () => { + const answers = await allThree(new Error('connection reset')); + expect(answers.map(([, a]) => a.status)).toEqual([500, 500, 500]); + expect(answers.map(([, a]) => a.body.error.code)).toEqual([ + 'SHARES_LIST_FAILED', 'SHARE_GRANT_FAILED', 'SHARE_REVOKE_FAILED', + ]); + for (const [route, a] of answers) { + expect(a.body.error.message, route).toBe('connection reset'); + expect('declaredCode' in a.body.error, route).toBe(false); + } + }); + + it('a NON-string `code` is context, not a wire spelling (the #3842 drift)', async () => { + // A numeric driver errno never declares an envelope, so this stays the + // route's own 500 and nothing is demoted onto it. Pinned because a + // door that read `body.declaredCode` without a type check would be + // stamping a NUMBER into a string channel on exactly this shape. + const answers = await allThree(refusal('driver errno', { code: 1234, status: 409 })); + for (const [route, a] of answers) { + expect(a.status, route).toBe(500); + expect('declaredCode' in a.body.error, route).toBe(false); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// §5 The wire answer IS the shared rule, not a second copy of it +// ───────────────────────────────────────────────────────────────────────────── + +describe('[#12510] the wire `declaredCode` IS `demotedDeclaredCode`, not a local re-derivation', () => { + /** + * The literal cases above say what the answers ARE, which is what a reader + * needs; they do not on their own keep this door with its siblings. A + * second demote rule written at this call site could satisfy every literal + * above and still diverge on the next throw shape nobody enumerated — + * which is how the two `/api/v1/packages` doors came to disagree. So the + * door is pinned to the shared function itself, read through the same + * status the classification resolved (which is how the flat door's + * `thrownCodeFields` calls it). + */ + const SHAPES: unknown[] = [ + ...DEMOTED.map((d) => d.error), + ...ABSENT.map((a) => a.error), + refusal('two rows still reference this account', { + code: 'CLOSE_PERIOD_LOCKED', status: 409, + issues: [{ path: 'invoices', message: 'inv_1 is still open' }], + }), + ]; + + it('every classified shape answers exactly what the shared rule says', async () => { + for (const shape of SHAPES) { + const classified = classifiedRefusalAnswer(shape); + if (!classified) continue; + const expected = demotedDeclaredCode( + resolveThrownHttpError(shape, classified.status), + ); + for (const [route, answer] of await allThree(shape)) { + expect( + answer.body?.error?.declaredCode, + `${route}: ${JSON.stringify(answer.body)}`, + ).toBe(expected); + } + } + }); + + it('the shapes above do not all answer the same thing', () => { + // Anti-vacuity for the comparison itself. + const answers = SHAPES + .map((s) => { + const classified = classifiedRefusalAnswer(s); + return classified + ? demotedDeclaredCode(resolveThrownHttpError(s, classified.status)) + : undefined; + }); + expect(answers.filter((a) => a !== undefined).length).toBeGreaterThan(2); + expect(answers.filter((a) => a === undefined).length).toBeGreaterThan(2); + }); +});