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..976d8b3741
--- /dev/null
+++ b/packages/rest/src/rest-data-door-code-prefix.test.ts
@@ -0,0 +1,400 @@
+// 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.
+ *
+ * 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';
+// `.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 () => {
+ // 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 'showcase_inquiry' 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 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;
+ // (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}`);
+ });
+});