From 73c342bd24a450beb89b03194bb092f509403758 Mon Sep 17 00:00:00 2001 From: os-litant Date: Sat, 29 Aug 2026 04:46:20 +0000 Subject: [PATCH 1/3] fix(rest): strip the ADR-0111 CODE: prefix from the /data declared-4xx error string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `/data` door's declared-4xx arm shipped the producer's message verbatim, so a refusal using the ADR-0111 `CODE: message` idiom put the machine token in front of the human sentence a client renders. Maintainer ruling 2026-08-29: one envelope semantics — `error` is human language, `code` is the machine token already carried by `thrownCodeFields`. The strip is anchored to the producer's own declared `code`, so it can only ever remove a duplicate of something already on the wire: a 4xx with no `code` keeps its prefix, and a prefix naming some other word is left alone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd --- .../data-door-strips-adr-0111-code-prefix.md | 46 +++ packages/rest/src/error-response.ts | 76 +++- .../src/rest-4xx-message-truncation.test.ts | 16 +- .../src/rest-5xx-status-passthrough.test.ts | 18 +- .../src/rest-data-door-code-prefix.test.ts | 383 ++++++++++++++++++ 5 files changed, 534 insertions(+), 5 deletions(-) create mode 100644 .changeset/data-door-strips-adr-0111-code-prefix.md create mode 100644 packages/rest/src/rest-data-door-code-prefix.test.ts diff --git a/.changeset/data-door-strips-adr-0111-code-prefix.md b/.changeset/data-door-strips-adr-0111-code-prefix.md new file mode 100644 index 0000000000..e1217177ce --- /dev/null +++ b/.changeset/data-door-strips-adr-0111-code-prefix.md @@ -0,0 +1,46 @@ +--- +"@objectstack/rest": patch +--- + +fix(rest): the `/data` door's declared-4xx body carries human language in `error`, not the ADR-0111 `CODE:` prefix (#12975) + +A producer that refuses with the ADR-0111 `CODE: message` idiom *and* declares +`{ code, status }` — `plugin-sharing`'s by-id write gate is the live in-repo +example — reached `/data` clients with the machine token glued to the front of +the human sentence. Since the sharing denial's copy moved onto the Operation +Message Catalog, a zh-CN user read this in a toast: + +```text +FROM {"error":"FORBIDDEN: 您无权修改或删除这条记录,如需修改请联系该记录的负责人或管理员。","code":"FORBIDDEN","object":"showcase_inquiry"} +TO {"error":"您无权修改或删除这条记录,如需修改请联系该记录的负责人或管理员。","code":"FORBIDDEN","object":"showcase_inquiry"} +``` + +Maintainer ruling, 2026-08-29: one envelope semantics — `error` is human +language, `code` is the machine token. The token is unchanged and still on the +wire; only its duplicate inside the sentence is gone, so a client keying on +`code` (or on the `declaredCode` sibling for an unregistered spelling) reads +exactly what it read before. + +**Scope — the strip is anchored to the producer's own declared `code`**, not to +a SCREAMING_SNAKE-then-colon pattern. Three consequences, each pinned: + +- a declared 4xx carrying **no** `code` keeps its prefix, because the token + rides nowhere else on that body and dropping it would be a loss rather than a + move; +- a message opening with some **other** capitalised word and a colon is left + alone — driver prose such as a SQLite "no such table" line is untouched; +- a message that is **nothing but** the prefix degrades to `Request failed`, + the same generic sentence an absent or empty message already produced. + +Every other branch of the door is byte-identical: declared 5xx (prose still +withheld whole), undeclared errors, the sandbox-refusal unwrap, +`DELETE_RESTRICTED`, `OBJECT_NOT_FOUND`, and any 4xx whose message never +carried the idiom. + +**Consumer census** (recorded per the ruling's precondition): nothing parses +meaning out of the prefix. The only readers that touch it on the wire are three +display-side strippers in `objectui` (`packages/react/src/utils/error-message.ts`, +`plugin-detail`'s `InlineEditSaveBar.tsx` and `DetailView.tsx`), which delete it +for rendering and become no-ops. The prefix readers inside this repo all run +**in-process**, upstream of the wire — the `rest-server.ts` route mappings and +one `plugin-email` check on an error it threw itself — and are untouched. diff --git a/packages/rest/src/error-response.ts b/packages/rest/src/error-response.ts index bb8bde347f..25ed097978 100644 --- a/packages/rest/src/error-response.ts +++ b/packages/rest/src/error-response.ts @@ -429,6 +429,54 @@ function thrownCodeFields(error: any, status: number): { code?: string; declared return { code: thrown.code, ...(demoted !== undefined ? { declaredCode: demoted } : {}) }; } +/** + * [#12975] The caller-facing half of an ADR-0111 `CODE: message` throw — the + * message with the leading restatement of the producer's OWN declared `code` + * removed, or the message unchanged when it carries no such restatement. + * + * ## The rule, and why it is anchored to the declared code + * + * Maintainer ruling, 2026-08-29, on the `/data` door shipping `FORBIDDEN:` in + * front of a localized refusal: ONE envelope semantics — `error` is HUMAN + * LANGUAGE and `code` is the MACHINE TOKEN, and the token is already carried + * separately by {@link thrownCodeFields} above. The prefix is therefore removed + * *because* the same fact rides the `code` axis, and that is exactly the + * condition this function tests: the message opens with the producer's own + * `code`, followed by a colon. + * + * ⛔ NOT a blanket SCREAMING_SNAKE-then-colon strip, and the difference is the + * whole safety argument. The broader shape removes a token the wire may carry + * NOWHERE else — a 4xx that declared a `status` but no `code` gets `{}` from + * {@link thrownCodeFields} (ADR-0112's own rule: nothing is invented for the + * half the producer did not name), so a blind strip would delete the token + * outright rather than move it to its axis. It also eats any sentence that + * merely opens with a capitalised word and a colon — driver prose such as a + * SQLite "no such table" line included. Anchored to the declared code, the + * strip can only ever remove a DUPLICATE of something already on the wire. + * + * ⚠️ The anchor is the PRODUCER's spelling (`error.code`), not the narrowed + * wire `code`. An unregistered spelling is demoted to a `declaredCode` sibling + * by {@link thrownCodeFields} (#9232) while the prefix restates the spelling + * the producer actually wrote, so comparing against the narrowed value would + * miss precisely the idiom this reads. Either way the token still reaches the + * wire — as `code`, or as the `declaredCode` beside it. + * + * This is the shape `respondSharingError`'s ADR-0111 prefix arm already applies + * in `rest-server.ts` (it strips the prefix naming the code it just answered), + * rather than a third local rule: strip the prefix that names the code being + * answered, never an arbitrary one. + */ +function withoutDeclaredCodePrefix(message: string, error: any): string { + const declared = typeof error?.code === 'string' && error.code.length > 0 + ? error.code + : undefined; + if (declared === undefined || !message.startsWith(declared)) return message; + const separator = /^:\s*/.exec(message.slice(declared.length)); + return separator === null + ? message + : message.slice(declared.length + separator[0].length); +} + /** * [#11718] The DECLARED-SERVER-FAULT relay, as one definition instead of a * shape each door re-derives: a producer that declared a 5xx keeps its @@ -1016,8 +1064,32 @@ function classifyDataError(error: any, object?: string): { status: number; body: // An over-long message is TRUNCATED, not swapped for generic text // (#5423) — see {@link truncateClientMessage}. A missing or empty one // still degrades to `'Request failed'`: there is nothing to truncate. - const msg = typeof error?.message === 'string' && error.message.length > 0 - ? truncateClientMessage(error.message) + // + // [#12975] …and the sentence it keeps is the HUMAN half only. A + // producer using the ADR-0111 `CODE: message` idiom restates on the + // MESSAGE axis a token this body already carries on the `code` axis + // ({@link thrownCodeFields}, three lines down), and that restatement + // was reaching Console's toast in front of a localized sentence — + // `FORBIDDEN: 您无权修改或删除这条记录…` — where it was the only + // non-human fragment left in the user's face. Maintainer ruling, + // 2026-08-29: one envelope semantics, `error` = human language, + // `code` = the machine token. {@link withoutDeclaredCodePrefix} carries + // why the strip is anchored to the producer's declared code rather than + // to a SCREAMING_SNAKE shape. + // + // ⛔ The strip runs BEFORE the bound, not after: #5423's budget belongs + // to the text addressed to the caller and the prefix is not that text, + // so truncating first would spend part of the caller's 500 characters + // on a token they must not read. + // + // A message that is NOTHING BUT the prefix degrades to 'Request failed' + // through the same limb an absent or empty one takes — there is no + // human half to ship, and the token rides `code` regardless. + const authored = typeof error?.message === 'string' + ? withoutDeclaredCodePrefix(error.message, error) + : ''; + const msg = authored.length > 0 + ? truncateClientMessage(authored) : 'Request failed'; // [#9232] Same narrowing as the 5xx arm above. The gate this replaces // (`typeof error?.code === 'string' && error.code`) is exactly the diff --git a/packages/rest/src/rest-4xx-message-truncation.test.ts b/packages/rest/src/rest-4xx-message-truncation.test.ts index 0e647957ef..8f67e39550 100644 --- a/packages/rest/src/rest-4xx-message-truncation.test.ts +++ b/packages/rest/src/rest-4xx-message-truncation.test.ts @@ -120,11 +120,25 @@ describe('mapDataError: 4xx passthrough truncates an over-long message (#5423)', describe('mapDataError: short 4xx messages are byte-for-byte unchanged (#5423)', () => { it('a normal-length message passes through with no ellipsis and no slicing', () => { + // [#12975] PIN MOVED, with the ruled behaviour change in the same PR + // (maintainer, 2026-08-29). This case pins the BOUND — no ellipsis, no + // slicing — and its fixture happens to use the ADR-0111 `CODE: message` + // idiom, so the declared-4xx arm now hands the caller the HUMAN half + // alone. The subject of the case is unchanged: what comes back is the + // authored sentence entire, not a truncation of it. + // + // ⛔ Both halves are asserted deliberately. Reading only `error` would + // pass just as well for the OTHER way of getting this wrong — dropping + // the machine token along with the prefix — so `code` is pinned beside + // it. The token moves axis; it does not leave the body. const msg = 'FORBIDDEN: insufficient privileges to update showcase_inquiry rec1'; + const human = 'insufficient privileges to update showcase_inquiry rec1'; const r = mapDataError(Object.assign(new Error(msg), { code: 'FORBIDDEN', status: 403 })); expect(r.status).toBe(403); - expect(r.body.error).toBe(msg); + expect(r.body.error).toBe(human); + expect(r.body.code).toBe('FORBIDDEN'); + expect(String(r.body.error).endsWith('…')).toBe(false); }); it('exactly 499 characters is still verbatim; exactly 500 is the first truncated length', () => { diff --git a/packages/rest/src/rest-5xx-status-passthrough.test.ts b/packages/rest/src/rest-5xx-status-passthrough.test.ts index ba865d8387..a6779264bc 100644 --- a/packages/rest/src/rest-5xx-status-passthrough.test.ts +++ b/packages/rest/src/rest-5xx-status-passthrough.test.ts @@ -297,11 +297,25 @@ describe('[#5582] nothing of a 5xx message reaches the client', () => { // --------------------------------------------------------------------------- describe('[#5582] the 4xx half and the structured branches are untouched', () => { - it('a short 4xx is still byte-for-byte verbatim, with its object', () => { + it('a short 4xx keeps its authored sentence — nothing withheld — with its object', () => { + // [#12975] PIN MOVED, with the ruled behaviour change in the same PR + // (maintainer, 2026-08-29). §4's subject is the 5xx WITHHOLD not + // reaching the 4xx half, and that is unchanged: the caller still gets + // the producer's sentence rather than `INTERNAL_ERROR_MESSAGE`. What + // moved is that the ADR-0111 `CODE:` prefix this fixture carries is no + // longer part of that sentence — `error` is human language, `code` is + // the machine token, and the whole body is asserted here so losing the + // token with the prefix would red rather than pass. + // + // The title lost the words "byte-for-byte verbatim" for the same + // reason: the AUTHORED half is verbatim, the restatement of `code` in + // front of it is not part of what was authored for the caller. const msg = 'FORBIDDEN: insufficient privileges to update showcase_inquiry rec1'; + const human = 'insufficient privileges to update showcase_inquiry rec1'; const r = mapDataError(Object.assign(new Error(msg), { code: 'FORBIDDEN', status: 403 }), 'showcase_inquiry'); expect(r.status).toBe(403); - expect(r.body).toEqual({ error: msg, code: 'FORBIDDEN', object: 'showcase_inquiry' }); + expect(r.body).toEqual({ error: human, code: 'FORBIDDEN', object: 'showcase_inquiry' }); + expect(r.body.error).not.toBe(INTERNAL_ERROR_MESSAGE); }); it('a long 4xx is still TRUNCATED rather than withheld (#5423)', () => { diff --git a/packages/rest/src/rest-data-door-code-prefix.test.ts b/packages/rest/src/rest-data-door-code-prefix.test.ts new file mode 100644 index 0000000000..37fdd4b58c --- /dev/null +++ b/packages/rest/src/rest-data-door-code-prefix.test.ts @@ -0,0 +1,383 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#12975] The `/data` door's declared-4xx arm ships HUMAN LANGUAGE in `error`. + * + * --------------------------------------------------------------------------- + * The ruling + * --------------------------------------------------------------------------- + * Maintainer, 2026-08-29 (issue #12975, option 1): the `/data` door's + * declared-4xx arm strips the ADR-0111 `CODE:` prefix from the human-readable + * `error` string, converging with the share-route family — ONE envelope + * semantics: `error` = human language, `code` = the machine token (already + * carried separately via `thrownCodeFields`). + * + * What the user was reading before it, on a zh-CN deployment, through + * `PATCH /api/v1/data/:object/:id`: + * + * FORBIDDEN: 您无权修改或删除这条记录,如需修改请联系该记录的负责人或管理员。 + * + * The actionable half is localized; the machine token glued in front of it was + * the only non-human fragment left in the toast. + * + * --------------------------------------------------------------------------- + * Why every case asserts BOTH halves + * --------------------------------------------------------------------------- + * There are two ways to get this wrong and only one of them is the defect. + * Asserting `error` alone passes just as happily for the other one — dropping + * the machine token along with the prefix — so each case pins the token's new + * home (`code`, or the `declaredCode` sibling when the spelling is + * unregistered) beside the sentence. The token moves AXIS; it never leaves the + * body. + * + * --------------------------------------------------------------------------- + * Driven through the real routes + * --------------------------------------------------------------------------- + * Every case boots a real `RestServer`, registers the real routes and calls the + * registered handler, so what is asserted is the wire body a client receives — + * not a hand-built envelope agreeing with a hand-built expectation. + * + * --------------------------------------------------------------------------- + * Anti-vacuity — directions predicted BEFORE running, measured after + * --------------------------------------------------------------------------- + * Ablation leg: this file run with ONLY `error-response.ts` reverted to the + * pre-fix bytes (fix committed first; revert `git checkout -- `, restore `git checkout HEAD -- `, both under + * `trap … EXIT INT TERM`, the mutation proven on disk by grepping the removed + * text and comparing blob hashes, the restore proven by `git diff HEAD` empty). + * + * No rebuild between legs, and that is load-bearing rather than an omission: + * every symbol under test is reached by a RELATIVE import inside this package, + * which vitest transforms from source — no `dist/` sits between the mutation + * and the assertion. The `exports`-resolved workspace deps (`@objectstack/types`, + * `@objectstack/spec`) are untouched by the mutation. + * + * §1 predicted RED — the ruled behaviour is what the revert removes. + * §2 predicted GREEN — the branches the ruling does not touch. These are + * the real controls: a fix that stripped by PATTERN + * instead of by the declared code reddens here (the + * no-code and non-matching-prefix cases) and nowhere + * else. + * §3 predicted RED — the demoted-spelling case rides the same arm. + * §4 predicted RED — the degradation is part of the new arm. + * §5 predicted GREEN — the share family is untouched by this card, in both + * the arm that already stripped and the two exits + * measured still carrying the prefix. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +// `.js` extension deliberately: this package resolves `nodenext`, so an +// extensionless relative import is a `tsc` error (TS2835). +import { RestServer } from './rest-server.js'; + +const ITEM = '/api/v1/data/:object/:id'; +const COLLECTION = '/api/v1/data/:object'; +const SHARES = '/api/v1/data/:object/:id/shares'; + +/** The sentence #12260 put on the wire, verbatim — the reason this card exists. */ +const ZH = '您无权修改或删除这条记录,如需修改请联系该记录的负责人或管理员。'; + +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, _body: undefined }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res._body = b; return res; }); + res.header = vi.fn(() => res); + res.setHeader = vi.fn(() => res); + res.write = vi.fn(); + res.end = vi.fn(() => res); + res.send = vi.fn(() => res); + return res; +} + +/** + * @param protocolOverrides the data-protocol methods the route calls. + * @param sharingService what `sharingServiceProvider` resolves to; omit to + * leave the record-share routes unserved. + */ +function boot(protocolOverrides: Record = {}, sharingService?: any) { + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ + version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' }, + }), + getMetaTypes: vi.fn().mockResolvedValue([]), + getMetaItems: vi.fn().mockResolvedValue([{ name: 'showcase_inquiry' }]), + getMetaItem: vi.fn().mockResolvedValue({}), + findData: vi.fn().mockResolvedValue([]), + createData: vi.fn().mockResolvedValue({}), + updateData: vi.fn().mockResolvedValue({}), + deleteData: vi.fn().mockResolvedValue({}), + batchData: vi.fn().mockResolvedValue({}), + createManyData: vi.fn().mockResolvedValue({}), + updateManyData: vi.fn().mockResolvedValue({}), + deleteManyData: vi.fn().mockResolvedValue({}), + ...protocolOverrides, + }; + const rest = new RestServer( + mockServer() as any, protocol, { api: { requireAuth: false } } as any, + undefined, undefined, undefined, undefined, undefined, undefined, + sharingService === undefined ? undefined : (async () => sharingService) as any, + ); + (rest as any).resolveExecCtx = async () => ({ userId: 'u1' }); + rest.registerRoutes(); + return rest; +} + +async function call(rest: any, method: string, path: string, req: Record) { + const found = rest.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, query: {}, headers: {}, params: {}, body: {}, ...req }, res); + return { status: res.statusCode, body: res._body }; +} + +const thrown = (message: string, extra: Record = {}) => + Object.assign(new Error(message), extra); + +/** `sharing-plugin.ts`'s by-id write gate, verbatim: the card's live producer. */ +const sharingWriteRefusal = () => + thrown(`FORBIDDEN: ${ZH}`, { code: 'FORBIDDEN', status: 403 }); + +const patchWith = (error: unknown) => call( + boot({ updateData: vi.fn().mockRejectedValue(error) }), + 'PATCH', ITEM, { params: { object: 'showcase_inquiry', id: 'rec1' }, body: { name: 'x' } }, +); +const deleteWith = (error: unknown) => call( + boot({ deleteData: vi.fn().mockRejectedValue(error) }), + 'DELETE', ITEM, { params: { object: 'showcase_inquiry', id: 'rec1' } }, +); + +let errorSpy: ReturnType; +beforeEach(() => { errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); }); +afterEach(() => { errorSpy.mockRestore(); }); + +// --------------------------------------------------------------------------- +// §1 The ruled behaviour, on the two routes the report was filed from +// --------------------------------------------------------------------------- + +describe('[#12975] the declared-4xx arm hands the caller the human half only', () => { + it('PATCH /data/:object/:id — the localized sentence arrives with no machine prefix', async () => { + const answer = await patchWith(sharingWriteRefusal()); + + expect(answer.status).toBe(403); + // The half a user reads: human language, and ONLY human language. + expect(answer.body.error).toBe(ZH); + expect(String(answer.body.error).startsWith('FORBIDDEN')).toBe(false); + // The other half of the same fact: the machine token is still on the + // wire, on the axis that owns it. Without this assertion the case + // above would also pass for "the token was dropped entirely". + expect(answer.body.code).toBe('FORBIDDEN'); + }); + + it('DELETE /data/:object/:id — one key serves both write verbs', async () => { + const answer = await deleteWith(sharingWriteRefusal()); + + expect(answer.status).toBe(403); + expect(answer.body.error).toBe(ZH); + expect(answer.body.code).toBe('FORBIDDEN'); + }); + + it('the sentence a zh-CN user reads carries no Latin prose at all', async () => { + // The end-user-facing claim #12260 made, now true end to end: before + // this card the body opened with `FORBIDDEN:` and this assertion was + // false on the wire while being true inside the plugin. + const answer = await patchWith(sharingWriteRefusal()); + expect(String(answer.body.error)).not.toMatch(/[A-Za-z]/); + }); + + it('a producer-declared `userMessage` still rides the body, unchanged', async () => { + const answer = await patchWith( + thrown(`FORBIDDEN: ${ZH}`, { code: 'FORBIDDEN', status: 403, userMessage: '请联系管理员' }), + ); + expect(answer.body).toEqual({ + error: ZH, code: 'FORBIDDEN', object: 'showcase_inquiry', userMessage: '请联系管理员', + }); + }); +}); + +// --------------------------------------------------------------------------- +// §2 What must NOT move — measured byte-identical before and after +// --------------------------------------------------------------------------- + +describe('[#12975] every branch that is not the declared-4xx idiom is untouched', () => { + it('a declared 4xx whose message carries no prefix is unchanged', async () => { + const answer = await patchWith( + thrown('insufficient privileges', { code: 'FORBIDDEN', status: 403 }), + ); + expect(answer.body).toEqual({ + error: 'insufficient privileges', code: 'FORBIDDEN', object: 'showcase_inquiry', + }); + }); + + it('⭐ a declared 4xx with NO `code` KEEPS its prefix — the token is nowhere else', async () => { + // The control that rules out a blanket SCREAMING_SNAKE strip. + // `thrownCodeFields` answers `{}` for a producer that named no code + // (ADR-0112: nothing is invented for the half it did not declare), so + // stripping here would delete the only machine token in the response + // rather than move it to its axis. + const answer = await patchWith(thrown(`FORBIDDEN: ${ZH}`, { status: 403 })); + expect(answer.body).toEqual({ + error: `FORBIDDEN: ${ZH}`, object: 'showcase_inquiry', + }); + expect('code' in answer.body).toBe(false); + }); + + it('⭐ a prefix that does not name the declared code is left alone — driver prose stays', async () => { + // The second control: the strip is anchored to the producer's own + // `code`, so a message opening with some other capitalised word and a + // colon is not eaten by it. + const answer = await patchWith( + thrown('SQLITE_ERROR: no such table: showcase_inquiry', { code: 'FORBIDDEN', status: 400 }), + ); + expect(answer.body.error).toBe('SQLITE_ERROR: no such table: showcase_inquiry'); + }); + + it('a declared 5xx still withholds its prose entirely (#5437/#5582)', async () => { + const answer = await patchWith( + thrown('SERVICE_UNAVAILABLE: pool down', { code: 'SERVICE_UNAVAILABLE', status: 503 }), + ); + expect(answer.status).toBe(503); + expect(answer.body.code).toBe('SERVICE_UNAVAILABLE'); + expect(String(answer.body.error)).not.toContain('pool down'); + expect(String(answer.body.error)).not.toContain('SERVICE_UNAVAILABLE:'); + }); + + it('an undeclared error still reaches the sanitised 500 terminal', async () => { + const answer = await patchWith(thrown('FORBIDDEN: something')); + expect(answer.status).toBe(500); + expect(answer.body.code).toBe('INTERNAL_ERROR'); + }); + + it('a sandbox refusal still answers 400 with the author’s own sentence', async () => { + const answer = await patchWith(Object.assign( + thrown("hook 'guard' threw: Error: Opportunity is closed."), + { name: 'SandboxError', innerMessage: 'Opportunity is closed.' }, + )); + expect(answer.status).toBe(400); + expect(answer.body.error).toBe('Opportunity is closed.'); + }); + + it('DELETE_RESTRICTED answers from the arm ABOVE the passthrough, prefix and all', async () => { + const answer = await deleteWith(thrown('DELETE_RESTRICTED: dependents exist', { + code: 'DELETE_RESTRICTED', status: 409, dependentCount: 3, + })); + expect(answer.status).toBe(409); + expect(answer.body.error).toBe('DELETE_RESTRICTED: dependents exist'); + }); + + it('OBJECT_NOT_FOUND keeps its canonical 404 body', async () => { + const answer = await patchWith(thrown("Object 'zz' is not registered", { + code: 'OBJECT_NOT_FOUND', status: 404, + })); + expect(answer.status).toBe(404); + expect(answer.body.code).toBe('OBJECT_NOT_FOUND'); + expect(answer.body.error).toBe("Object 'zz' is not registered"); + }); +}); + +// --------------------------------------------------------------------------- +// §3 An UNREGISTERED spelling — the token lands on `declaredCode` +// --------------------------------------------------------------------------- + +describe('[#12975] a demoted spelling still carries the token beside the sentence', () => { + it('the prefix is read against the PRODUCER’s spelling, not the narrowed `code`', async () => { + // #9232 demotes an unregistered thrown spelling to a `declaredCode` + // sibling and fills `code` from the status. The prefix restates what + // the producer WROTE, so anchoring the strip on the narrowed value + // would miss exactly this shape — and the token is still on the wire, + // one field over. + const answer = await patchWith(thrown('RECORD_LOCKED_BY_APP: the row is checked out', { + code: 'RECORD_LOCKED_BY_APP', status: 423, + })); + expect(answer.status).toBe(423); + expect(answer.body.error).toBe('the row is checked out'); + expect(answer.body.declaredCode).toBe('RECORD_LOCKED_BY_APP'); + expect(typeof answer.body.code).toBe('string'); + }); +}); + +// --------------------------------------------------------------------------- +// §4 A message that is nothing BUT the prefix +// --------------------------------------------------------------------------- + +describe('[#12975] a message with no human half degrades rather than shipping a bare token', () => { + it('`CODE:` alone becomes the generic sentence, with the token still on `code`', async () => { + const answer = await patchWith(thrown('FORBIDDEN:', { code: 'FORBIDDEN', status: 403 })); + expect(answer.body.error).toBe('Request failed'); + expect(answer.body.code).toBe('FORBIDDEN'); + }); +}); + +// --------------------------------------------------------------------------- +// §5 The share-route family — what converged, and what measurably did NOT +// --------------------------------------------------------------------------- + +describe('[#12975] the share family: convergence, and the two exits still carrying the prefix', () => { + const throwingShareService = (error: unknown) => ({ + listShares: vi.fn().mockRejectedValue(error), + grant: vi.fn().mockRejectedValue(error), + revoke: vi.fn().mockRejectedValue(error), + }); + + it('the ADR-0111 prefix-idiom arm still strips — unchanged by this card', async () => { + const answer = await call( + boot({}, throwingShareService(thrown('NOT_FOUND: record showcase_inquiry/rec1 does not exist'))), + 'GET', SHARES, { params: { object: 'showcase_inquiry', id: 'rec1' } }, + ); + expect(answer.status).toBe(404); + expect(answer.body.error.code).toBe('NOT_FOUND'); + expect(answer.body.error.message).toBe('record showcase_inquiry/rec1 does not exist'); + }); + + it('CONVERGENCE — that arm and the `/data` door now answer one semantics', async () => { + // The same fact stated from both doors: neither puts the machine token + // inside the sentence, and both keep it on the code axis. This is the + // pin the ruling asked for; it reds if either door starts disagreeing + // again, whichever one moves. + const shareAnswer = await call( + boot({}, throwingShareService(thrown(`PERMISSION_DENIED: ${ZH}`))), + 'GET', SHARES, { params: { object: 'showcase_inquiry', id: 'rec1' } }, + ); + const dataAnswer = await patchWith(sharingWriteRefusal()); + + expect(shareAnswer.body.error.message).toBe(ZH); + expect(dataAnswer.body.error).toBe(ZH); + for (const sentence of [shareAnswer.body.error.message, dataAnswer.body.error]) { + expect(String(sentence)).not.toMatch(/^[A-Z][A-Z0-9_]*:/); + } + expect(shareAnswer.body.error.code).toBe('PERMISSION_DENIED'); + expect(dataAnswer.body.code).toBe('FORBIDDEN'); + }); + + it('⚠️ MEASURED, NOT REPAIRED HERE — two exits still ship the prefix', async () => { + // Recorded rather than fixed: the ruling moved ONE arm, and both exits + // below are reached through `resolveErrorResponse`'s own declared-4xx + // passthrough, which it did not name. Filed for the maintainer; this + // case is the evidence, and it REDS the day either exit is converged, + // which is the point — the follow-up moves it deliberately instead of + // discovering the divergence a third time. + // + // (a) the record-share family's CLASSIFIED arm — a producer that + // declared `{ code, status }` AND used the prefix idiom; + // (b) `/data`'s bulk exits (batch / createMany / updateMany / + // deleteMany / clone), which report through `handleRouteError`. + const classified = await call( + boot({}, throwingShareService(sharingWriteRefusal())), + 'GET', SHARES, { params: { object: 'showcase_inquiry', id: 'rec1' } }, + ); + expect(classified.body.error.message).toBe(`FORBIDDEN: ${ZH}`); + + const bulk = await call( + boot({ batchData: vi.fn().mockRejectedValue(sharingWriteRefusal()) }), + 'POST', `${COLLECTION}/batch`, + { params: { object: 'showcase_inquiry' }, body: { operation: 'update', records: [{ id: 'r1' }] } }, + ); + expect(bulk.body.error).toBe(`FORBIDDEN: ${ZH}`); + }); +}); From 8b3b52e97a3939b9bc4d9c6a6f3365dd7ebc1e8c Mon Sep 17 00:00:00 2001 From: os-litant Date: Sat, 29 Aug 2026 04:53:01 +0000 Subject: [PATCH 2/3] test(rest): pin OBJECT_NOT_FOUND against the sentence the arm re-derives The 404 arm above the declared-status passthrough builds its sentence from the route's own object name rather than echoing the producer's message, so the expectation names the route's object. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd --- packages/rest/src/rest-data-door-code-prefix.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/rest/src/rest-data-door-code-prefix.test.ts b/packages/rest/src/rest-data-door-code-prefix.test.ts index 37fdd4b58c..cfe0e84d8d 100644 --- a/packages/rest/src/rest-data-door-code-prefix.test.ts +++ b/packages/rest/src/rest-data-door-code-prefix.test.ts @@ -272,12 +272,16 @@ describe('[#12975] every branch that is not the declared-4xx idiom is untouched' }); it('OBJECT_NOT_FOUND keeps its canonical 404 body', async () => { + // The arm ABOVE the passthrough, so the strip is never consulted. Its + // sentence is re-derived from the route's own object name rather than + // echoed from the throw, which is why the expectation names + // `showcase_inquiry` and not whatever the producer wrote. const answer = await patchWith(thrown("Object 'zz' is not registered", { code: 'OBJECT_NOT_FOUND', status: 404, })); expect(answer.status).toBe(404); expect(answer.body.code).toBe('OBJECT_NOT_FOUND'); - expect(answer.body.error).toBe("Object 'zz' is not registered"); + expect(answer.body.error).toBe("Object 'showcase_inquiry' is not registered"); }); }); From c687d9ac9e32091b68402b45a3f9e09050d69985 Mon Sep 17 00:00:00 2001 From: os-litant Date: Sat, 29 Aug 2026 05:02:43 +0000 Subject: [PATCH 3/3] test(rest): record the measured ablation and link the filed follow-up The header now carries the predictions against what was measured, including the one prediction that was wrong (the CONVERGENCE case asserts both doors, so it reds under the revert), and the MEASURED-NOT-REPAIRED case names #13095. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd --- .../src/rest-data-door-code-prefix.test.ts | 43 ++++++++++++------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/packages/rest/src/rest-data-door-code-prefix.test.ts b/packages/rest/src/rest-data-door-code-prefix.test.ts index cfe0e84d8d..976d8b3741 100644 --- a/packages/rest/src/rest-data-door-code-prefix.test.ts +++ b/packages/rest/src/rest-data-door-code-prefix.test.ts @@ -52,17 +52,30 @@ * and the assertion. The `exports`-resolved workspace deps (`@objectstack/types`, * `@objectstack/spec`) are untouched by the mutation. * - * §1 predicted RED — the ruled behaviour is what the revert removes. - * §2 predicted GREEN — the branches the ruling does not touch. These are - * the real controls: a fix that stripped by PATTERN - * instead of by the declared code reddens here (the - * no-code and non-matching-prefix cases) and nowhere - * else. - * §3 predicted RED — the demoted-spelling case rides the same arm. - * §4 predicted RED — the degradation is part of the new arm. - * §5 predicted GREEN — the share family is untouched by this card, in both - * the arm that already stripped and the two exits - * measured still carrying the prefix. + * Measured on the revert, 9 red / 48 green across this file and the two moved + * pins, against predictions written first: + * + * §1 predicted RED 4 measured 4 red — as predicted. + * §2 predicted GREEN 8 measured green — as predicted, and these are the real + * controls: a fix that stripped by PATTERN instead of by + * the declared code reddens on the no-code and the + * non-matching-prefix cases and nowhere else. + * §3 predicted RED 1 measured 1 red — as predicted. + * §4 predicted RED 1 measured 1 red — as predicted. + * §5 predicted GREEN 3 measured 2 green, 1 RED. ⚠️ The prediction was WRONG + * and is recorded rather than re-fitted: `CONVERGENCE` + * asserts BOTH doors, so half of it reads the `/data` + * side the revert removes. Red is the correct answer + * for it. The other two are genuine controls and stayed + * green — the share family is untouched by this card, + * in the arm that already stripped and in the two exits + * measured still carrying the prefix. + * + * The two moved pins (`rest-4xx-message-truncation.test.ts`, + * `rest-5xx-status-passthrough.test.ts`) reddened one case each under the same + * revert and every OTHER case in both files stayed green — which is the check + * that exactly the two the ruling authorised moved, and nothing else was + * loosened to make room. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -362,10 +375,10 @@ describe('[#12975] the share family: convergence, and the two exits still carryi it('⚠️ MEASURED, NOT REPAIRED HERE — two exits still ship the prefix', async () => { // Recorded rather than fixed: the ruling moved ONE arm, and both exits // below are reached through `resolveErrorResponse`'s own declared-4xx - // passthrough, which it did not name. Filed for the maintainer; this - // case is the evidence, and it REDS the day either exit is converged, - // which is the point — the follow-up moves it deliberately instead of - // discovering the divergence a third time. + // passthrough, which it did not name. Filed for the maintainer as + // #13095; this case is the evidence, and it REDS the day either exit is + // converged, which is the point — the follow-up moves it deliberately + // instead of discovering the divergence a third time. // // (a) the record-share family's CLASSIFIED arm — a producer that // declared `{ code, status }` AND used the prefix idiom;