Skip to content

Commit 4633fd3

Browse files
committed
fix(rest): resolveErrorResponse's declared-4xx passthrough strips the declared-code prefix (#13095)
Spread the #12975 ruling's declared-code-anchored strip (withoutDeclaredCodePrefix) to resolveErrorResponse's passThroughStatus 4xx arm, converging every /data exit (batch/createMany/updateMany/deleteMany/ clone via handleRouteError) and, because the record-share classified arm re-dresses the same classification, the share family with it. Converge the approvals door's blanket /^[A-Z_]+:\s*/ strip onto the code the row answers (the regex shape #12975 rejected). Correct the #8111 comment's false mechanism claim in both homes (rest-server.ts and the sharing-envelope.test docblock) without over-claiming the measured consequence. Move the MEASURED-NOT-REPAIRED pin to the CONVERGENCE pin it announced, and add the moved arm's own anchoring controls. Per the 2026-08-31 maintainer ruling (option 1) on #13095. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk
1 parent 836a29c commit 4633fd3

4 files changed

Lines changed: 180 additions & 25 deletions

File tree

‎packages/rest/src/error-response.ts‎

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1762,11 +1762,44 @@ function resolveErrorResponse(error: any, object?: string): { status: number; bo
17621762
// means moving the STATUS this arm decided, which is the contract
17631763
// question this card was fenced away from — filed separately.
17641764
const businessMessage = sandboxBusinessMessage(error);
1765-
const safeMsg = businessMessage !== undefined
1766-
? truncateClientMessage(businessMessage)
1767-
: typeof error.message !== 'string'
1765+
// [#13095] The sentence this arm hands the caller is the HUMAN half
1766+
// only — the same #12975 rule `classifyDataError`'s declared-4xx arm
1767+
// applies, spread here by the 2026-08-31 maintainer ruling (option 1:
1768+
// one envelope semantics on every `/data` exit). This arm is checked
1769+
// BEFORE it delegates to `mapDataError`, so every route that reports
1770+
// through `handleRouteError` / `sendThrownError` (batch, createMany,
1771+
// updateMany, deleteMany, clone, and the record-share classified arm,
1772+
// which re-dresses this very answer through `classifiedRefusalAnswer`)
1773+
// was still shipping the ADR-0111 `CODE:` prefix the by-id door had
1774+
// stopped shipping — one refusal, two readings, decided by which
1775+
// route caught it.
1776+
//
1777+
// Anchored to the DECLARED code ({@link withoutDeclaredCodePrefix} —
1778+
// ⛔ never a SCREAMING_SNAKE pattern; that function's docblock carries
1779+
// the safety argument), and run BEFORE the bound for #12975's reason:
1780+
// the prefix is not text addressed to the caller, so it must not
1781+
// spend the caller's #5423 budget. A message that is nothing but the
1782+
// prefix has no human half to ship and degrades to 'Request failed',
1783+
// the sibling arm's rule travelling WITH the strip.
1784+
//
1785+
// ⛔ What deliberately does NOT converge here: a genuinely EMPTY
1786+
// string message still ships as itself. This arm's degrade is keyed
1787+
// on the TYPE, unlike `classifyDataError`'s sibling which also checks
1788+
// length — a standing pin (`rest-hook-refusal-message-parity.test.ts`)
1789+
// this card's ruling did not authorise moving. The 'Request failed'
1790+
// limb below therefore fires only when the STRIP emptied a non-empty
1791+
// message, never for a message that arrived empty.
1792+
const addressed = businessMessage !== undefined
1793+
? businessMessage
1794+
: typeof error.message === 'string' ? error.message : undefined;
1795+
const authored = addressed === undefined
1796+
? undefined
1797+
: withoutDeclaredCodePrefix(addressed, error);
1798+
const safeMsg = authored === undefined
1799+
? 'Request failed'
1800+
: authored.length === 0 && addressed !== undefined && addressed.length > 0
17681801
? 'Request failed'
1769-
: truncateClientMessage(error.message);
1802+
: truncateClientMessage(authored);
17701803
// [#9232] Narrowed, same as the three arms above.
17711804
return withDeclaredUserMessage(error, {
17721805
status: error.status,

‎packages/rest/src/rest-data-door-code-prefix.test.ts‎

Lines changed: 102 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -372,13 +372,20 @@ describe('[#12975] the share family: convergence, and the two exits still carryi
372372
expect(dataAnswer.body.code).toBe('FORBIDDEN');
373373
});
374374

375-
it('⚠️ MEASURED, NOT REPAIRED HERE — two exits still ship the prefix', async () => {
376-
// Recorded rather than fixed: the ruling moved ONE arm, and both exits
377-
// below are reached through `resolveErrorResponse`'s own declared-4xx
378-
// passthrough, which it did not name. Filed for the maintainer as
379-
// #13095; this case is the evidence, and it REDS the day either exit is
380-
// converged, which is the point — the follow-up moves it deliberately
381-
// instead of discovering the divergence a third time.
375+
it('CONVERGENCE [#13095] — the two exits that used to ship the prefix no longer do', async () => {
376+
// ⚠️ MOVED DELIBERATELY. Until #13095 this case was titled
377+
// "MEASURED, NOT REPAIRED HERE" and pinned the OLD truth — both exits
378+
// below are reached through `resolveErrorResponse`'s declared-4xx
379+
// passthrough (checked BEFORE it delegates to `mapDataError`), which
380+
// the #12975 ruling did not name, so both still shipped
381+
// `FORBIDDEN: ${ZH}` while the by-id door had converged. That case
382+
// existed to red the day either exit converged, so the follow-up
383+
// would move it deliberately instead of discovering the divergence a
384+
// third time. This is that day: the 2026-08-31 maintainer ruling on
385+
// #13095 (option 1) put the same declared-code-anchored
386+
// `withoutDeclaredCodePrefix` in the passthrough arm, and the
387+
// record-share classified arm converges with it for free because it
388+
// re-dresses that same classification.
382389
//
383390
// (a) the record-share family's CLASSIFIED arm — a producer that
384391
// declared `{ code, status }` AND used the prefix idiom;
@@ -388,13 +395,99 @@ describe('[#12975] the share family: convergence, and the two exits still carryi
388395
boot({}, throwingShareService(sharingWriteRefusal())),
389396
'GET', SHARES, { params: { object: 'showcase_inquiry', id: 'rec1' } },
390397
);
391-
expect(classified.body.error.message).toBe(`FORBIDDEN: ${ZH}`);
398+
expect(classified.body.error.message).toBe(ZH);
399+
expect(classified.body.error.code).toBe('FORBIDDEN');
392400

393401
const bulk = await call(
394402
boot({ batchData: vi.fn().mockRejectedValue(sharingWriteRefusal()) }),
395403
'POST', `${COLLECTION}/batch`,
396404
{ params: { object: 'showcase_inquiry' }, body: { operation: 'update', records: [{ id: 'r1' }] } },
397405
);
398-
expect(bulk.body.error).toBe(`FORBIDDEN: ${ZH}`);
406+
expect(bulk.body.error).toBe(ZH);
407+
expect(bulk.body.code).toBe('FORBIDDEN');
408+
});
409+
});
410+
411+
// ---------------------------------------------------------------------------
412+
// §6 The passthrough arm's own controls — the strip stays ANCHORED (#13095)
413+
// ---------------------------------------------------------------------------
414+
415+
describe('[#13095] the bulk door strips by declared code, never by pattern', () => {
416+
const batchWith = (error: unknown) => call(
417+
boot({ batchData: vi.fn().mockRejectedValue(error) }),
418+
'POST', `${COLLECTION}/batch`,
419+
{ params: { object: 'showcase_inquiry' }, body: { operation: 'update', records: [{ id: 'r1' }] } },
420+
);
421+
422+
it('⭐ a declared 4xx with NO `code` KEEPS its prefix — the token is nowhere else', async () => {
423+
// §2's control, restated on the arm #13095 moved: `thrownCodeFields`
424+
// answers `{}` for a producer that named no code, so stripping here
425+
// would delete the only machine token in the response rather than
426+
// move it to its axis.
427+
const answer = await batchWith(thrown(`FORBIDDEN: ${ZH}`, { status: 403 }));
428+
expect(answer.status).toBe(403);
429+
expect(answer.body.error).toBe(`FORBIDDEN: ${ZH}`);
430+
expect('code' in answer.body).toBe(false);
431+
});
432+
433+
it('⭐ a prefix that does not name the declared code is left alone — driver prose stays', async () => {
434+
const answer = await batchWith(
435+
thrown('SQLITE_ERROR: no such table: showcase_inquiry', { code: 'FORBIDDEN', status: 400 }),
436+
);
437+
expect(answer.body.error).toBe('SQLITE_ERROR: no such table: showcase_inquiry');
438+
});
439+
440+
it('a message that is nothing but the prefix degrades to the generic sentence', async () => {
441+
const answer = await batchWith(thrown('FORBIDDEN:', { code: 'FORBIDDEN', status: 403 }));
442+
expect(answer.body.error).toBe('Request failed');
443+
expect(answer.body.code).toBe('FORBIDDEN');
444+
});
445+
});
446+
447+
// ---------------------------------------------------------------------------
448+
// §7 The approvals door — the third strip point, converged onto the anchor
449+
// ---------------------------------------------------------------------------
450+
451+
describe('[#13095] the approvals door strips the code it answers, never a blanket pattern', () => {
452+
const APPROVE = '/api/v1/approvals/requests/:id/approve';
453+
454+
const approveWith = (error: unknown) => {
455+
const protocol: any = {
456+
getDiscovery: vi.fn().mockResolvedValue({
457+
version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' },
458+
}),
459+
getMetaTypes: vi.fn().mockResolvedValue([]),
460+
getMetaItems: vi.fn().mockResolvedValue([]),
461+
findData: vi.fn().mockResolvedValue([]),
462+
};
463+
const rest = new RestServer(
464+
mockServer() as any, protocol, { api: { requireAuth: false } } as any,
465+
undefined, undefined, undefined, undefined, undefined, undefined,
466+
undefined, undefined,
467+
(async () => ({ decide: vi.fn().mockRejectedValue(error) })) as any,
468+
);
469+
(rest as any).resolveExecCtx = async () => ({ userId: 'u1' });
470+
rest.registerRoutes();
471+
return call(rest, 'POST', APPROVE, { params: { id: 'req_1' } });
472+
};
473+
474+
it('the well-formed idiom is unchanged: the answered code is stripped from the sentence', async () => {
475+
const answer = await approveWith(thrown(`FORBIDDEN: ${ZH}`));
476+
expect(answer.status).toBe(403);
477+
expect(answer.body.code).toBe('FORBIDDEN');
478+
expect(answer.body.error).toBe(ZH);
479+
});
480+
481+
it('⭐ a LONGER token sharing the matched spelling is NOT eaten — the blanket regex would have', async () => {
482+
// The distinguisher between the old `/^[A-Z_]+:\s*/` strip and the
483+
// anchored one: `/^FORBIDDEN/` matches this message, so the row
484+
// answers `code: 'FORBIDDEN'` — but the sentence opens with a
485+
// DIFFERENT token, which the wire carries nowhere else. The blanket
486+
// regex deleted it; the anchored strip removes only a duplicate of
487+
// the code being answered (#12975's rule, spread by #13095).
488+
const answer = await approveWith(thrown('FORBIDDEN_BY_POLICY: contact your administrator'));
489+
expect(answer.status).toBe(403);
490+
expect(answer.body.code).toBe('FORBIDDEN');
491+
expect(answer.body.error).toBe('FORBIDDEN_BY_POLICY: contact your administrator');
399492
});
400493
});

‎packages/rest/src/rest-server.ts‎

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10304,12 +10304,23 @@ export class RestServer {
1030410304
// signals the verdict via message prefixes, the plugin's established
1030510305
// error idiom — this maps them onto HTTP. Returns true when handled.
1030610306
//
10307-
// [#8111] The prefix is a SERVER-INTERNAL service→REST derivation: it
10308-
// is stripped below and never reaches the wire, so no consumer can
10309-
// read it (censused at claim — the only in-repo `startsWith(CODE)`
10310-
// readers are this file's own route mappings plus one
10311-
// `plugin-approvals` check on an error it threw itself in-process).
10312-
// It therefore stays exactly as it is; only the response SHAPE moved.
10307+
// [#8111] The prefix is a SERVER-INTERNAL service→REST derivation.
10308+
// ⚠️ [#13095] This comment used to claim the MECHANISM guaranteed
10309+
// that: "it is stripped below and never reaches the wire". That was
10310+
// true of the prefix-idiom arm it was written about and FALSE for the
10311+
// classified limb #11683 added beside it, which re-dresses
10312+
// `resolveErrorResponse`'s answer — an answer that shipped the prefix
10313+
// inside the sentence until the 2026-08-31 ruling converged that arm
10314+
// onto the same declared-code-anchored strip. Even now the strip is
10315+
// ANCHORED (it removes only a prefix restating the declared code), so
10316+
// "never reaches the wire" is not a mechanism anyone may lean on.
10317+
// What holds instead is MEASURED, not guaranteed: no consumer
10318+
// branches on the prefix in the wire `error` text — censused at
10319+
// #8111's claim and re-censused 2026-09-01 (objectstack + objectui:
10320+
// zero wire readers; every `startsWith(CODE)` hit is this file's own
10321+
// route mappings or an in-process producer-side check; `cloud` was
10322+
// not reachable and is NOT measured). The prefix idiom itself stays
10323+
// exactly as it is; #8111 moved only the response SHAPE.
1031310324
//
1031410325
// [#11683] …and it stays exactly as it is here too. What moved is that
1031510326
// the prefix read is no longer the FIRST question, and no longer the
@@ -11403,7 +11414,18 @@ export class RestServer {
1140311414
];
1140411415
for (const [re, status, code] of mapping) {
1140511416
if (re.test(msg)) {
11406-
res.status(status).json({ code, error: msg.replace(/^[A-Z_]+:\s*/, '') });
11417+
// [#13095] The strip is anchored to the CODE this row just
11418+
// answered — the same declared-code anchoring
11419+
// `withoutDeclaredCodePrefix` (error-response.ts) and
11420+
// `respondSharingError`'s prefix arm apply, converged here
11421+
// by the 2026-08-31 ruling. The blanket
11422+
// SCREAMING_SNAKE-colon regex that used to sit here
11423+
// (`/^[A-Z_]+:\s*/`) is exactly the shape #12975 rejected:
11424+
// it could eat a token the wire carries nowhere else (a
11425+
// message opening with a DIFFERENT capitalised word and a
11426+
// colon), where the anchored form can only ever remove a
11427+
// duplicate of the `code` already on the wire.
11428+
res.status(status).json({ code, error: msg.replace(new RegExp(`^${code}:\\s*`), '') });
1140711429
return true;
1140811430
}
1140911431
}

‎packages/rest/src/sharing-envelope.test.ts‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,18 @@
2424
*
2525
* `respondSharingError` recovers the verdict by parsing the service's message
2626
* text (`msg.startsWith(CODE)`) and then STRIPS that prefix before answering.
27-
* The prefix is a server-internal service→REST derivation that never reaches
28-
* the wire, so no consumer can read it — censused at claim. It is therefore
29-
* untouched here: this card moved the response SHAPE only. `stripsThePrefix`
30-
* below pins that the stripping still happens, so a future reader does not
31-
* "restore" a prefix the wire never carried.
27+
* The prefix is a server-internal service→REST derivation. ⚠️ [#13095] This
28+
* docblock used to say the mechanism guaranteed that ("never reaches the
29+
* wire") — false for the CLASSIFIED limb #11683 later added beside this arm,
30+
* which re-dressed `resolveErrorResponse`'s answer prefix and all until the
31+
* 2026-08-31 ruling converged that arm onto the same declared-code-anchored
32+
* strip. What holds is MEASURED, not guaranteed: no consumer branches on the
33+
* prefix in the wire text (censused at #8111's claim; re-censused 2026-09-01
34+
* over objectstack + objectui with zero wire readers; `cloud` unreachable,
35+
* NOT measured). The prefix idiom itself is untouched here: #8111 moved the
36+
* response SHAPE only. `stripsThePrefix` below pins that the stripping still
37+
* happens, so a future reader does not "restore" a prefix the wire does not
38+
* carry on this arm.
3239
*
3340
* ## What these cases assert, and why not `toThrow`
3441
*

0 commit comments

Comments
 (0)