Skip to content

Commit bb4ea80

Browse files
os-steveclaude
andauthored
fix(rest): converge every declared-4xx exit on the declared-code prefix strip (#13095) (#14120)
* 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 * docs(rest): record the #13095 ablation in the pin file header; changeset (minor + migration note) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk * fix(rest): docblock regex spelling terminated the block comment early (#13095) The header's ablation record spelled the approvals door's old blanket strip as a slash-delimited regex literal inside a block comment; its closing */ ended the comment 40 lines early. check:engine-double-contract's ts-parse refusal caught it. Spelled without delimiters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk * docs(changeset): carry the cloud NOT-MEASURED census bound into the shipping artifact (#13095) At-tier contract review finding on PR #14120: the changeset said a cross-repo census found zero prefix-branching consumers without naming objectstack-ai/cloud as unmeasured. The PR body and both #8111 comment homes carried the bound; the changeset -- the artifact that feeds release notes and reaches operators -- did not, so its zero read as 'checked everywhere'. The census statement now names the three repos it covered (each with a positive control) and states that cloud was NOT MEASURED and is deliberately not reported as clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 024bdb4 commit bb4ea80

5 files changed

Lines changed: 270 additions & 25 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/rest": minor
3+
---
4+
5+
fix(rest): every `/data` exit and the approvals door now hand the caller the human half of a declared-code-prefixed message (#13095)
6+
7+
The 2026-08-29 ruling on #12975 made the by-id `/data` door strip the
8+
ADR-0111 `CODE:` prefix from the human-readable `error` string — one
9+
envelope semantics: `error` is human language, `code` is the machine token.
10+
`resolveErrorResponse`'s declared-4xx `passThroughStatus` arm is a second
11+
declared-4xx arm the ruling did not name, checked BEFORE the door it
12+
delegates to — so the same refusal read two ways depending on which route
13+
caught it: `PATCH /data/:object/:id` answered the bare localized sentence
14+
while `POST /data/:object/batch` (and every bulk/clone exit reporting
15+
through `handleRouteError`) and the record-share classified arm still
16+
shipped `FORBIDDEN: <sentence>`. Maintainer ruling 2026-08-31 (option 1):
17+
converge them.
18+
19+
On-wire changes, all subtractive on message text only, all 4xx, statuses
20+
and `code`/`declaredCode` fields untouched:
21+
22+
- `resolveErrorResponse`'s declared-4xx arm now applies the same
23+
declared-code-anchored strip (`withoutDeclaredCodePrefix`) the by-id door
24+
applies: a message opening with the producer's own declared `code`
25+
followed by a colon loses that prefix — and only that prefix. A message
26+
that is nothing but the prefix degrades to `Request failed`. This
27+
converges the `/data` batch/createMany/updateMany/deleteMany/clone exits
28+
and — because the record-share classified arm re-dresses the same
29+
classification — `GET/POST/DELETE /data/:object/:id/shares*` with them.
30+
- The approvals door's prefix strip is now anchored to the code the row
31+
answers instead of the blanket `/^[A-Z_]+:\s*/` regex (the shape #12975
32+
rejected): a sentence opening with a DIFFERENT `SCREAMING_SNAKE:` token
33+
than the answered code is no longer eaten.
34+
35+
Not moved, deliberately: a declared 4xx with NO `code` keeps its message
36+
verbatim (the token is nowhere else on the wire); a prefix that does not
37+
restate the declared code is left alone; declared-5xx prose withholding is
38+
unchanged; the share family's bare-`Error` prefix-idiom arm already
39+
stripped and is untouched; an empty-string message through the passthrough
40+
still ships as itself (that TYPE-keyed degrade is a standing pin this
41+
ruling did not move).
42+
43+
**Migration.** Consumers that parsed the `CODE:` prefix off the front of
44+
`error` (flat `/data` doors) or `error.message` (nested record-share
45+
envelope) on these routes must read the `code` field instead — it has
46+
carried the same token all along, with unregistered spellings demoted to
47+
the `declaredCode` sibling (#9232). The consumer census behind this change
48+
covered `objectstack`, `objectui` and `hotcrm` (re-run 2026-09-01, each
49+
scope with a positive control) and found zero consumers branching on the
50+
prefix. `objectstack-ai/cloud` was NOT MEASURED — it was unreachable from
51+
the implementing session — and is deliberately not reported as clean: the
52+
zero above is a statement about the three repos named, not about every
53+
deployment. An operator whose code parses the leading token off these
54+
routes' error text should locate and update those reads before upgrading.
55+
Per #13347's precedent an error-envelope shape change ships as `minor`
56+
with this note.

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: 136 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,40 @@
7676
* revert and every OTHER case in both files stayed green — which is the check
7777
* that exactly the two the ruling authorised moved, and nothing else was
7878
* loosened to make room.
79+
*
80+
* ---------------------------------------------------------------------------
81+
* [#13095] §5's pin MOVED — 2026-08-31 maintainer ruling, option 1
82+
* ---------------------------------------------------------------------------
83+
* Everything above this line is the #12975-era record, kept as history.
84+
* #13095 spread the same declared-code-anchored strip to
85+
* `resolveErrorResponse`'s declared-4xx passthrough (and, through the one
86+
* classification the share family re-dresses, its classified arm), and
87+
* converged the approvals door's blanket `^[A-Z_]+:\s*` regex strip onto the
88+
* code the row answers — so §5's "MEASURED, NOT REPAIRED HERE" case, written
89+
* to red the day either exit converged, was MOVED deliberately to the
90+
* CONVERGENCE pin it announced, and §6/§7 pin the moved arms' own anchoring
91+
* controls.
92+
*
93+
* #13095 ablation (two legs, predictions written before running; mutation
94+
* AND restore each proven on disk by blob-hash equality against the named
95+
* rev plus single-occurrence anchor counts both ways; no rebuild between
96+
* legs — every subject is reached by RELATIVE in-package imports vitest
97+
* transforms from source, the same argument as above):
98+
*
99+
* Leg A — `error-response.ts` at pre-fix bytes: predicted exactly 2 red,
100+
* §5's CONVERGENCE pin and §6's nothing-but-prefix case; measured 2 red /
101+
* 50 green across this file + `rest-hook-refusal-message-parity.test.ts`,
102+
* which stayed ALL green — the empty-string TYPE-keyed degrade pinned
103+
* there (deliberately NOT converged; that pin was not this ruling's to
104+
* move) measurably did not move. §6's no-code and non-matching-prefix
105+
* cases are controls and stayed green on BOTH sides: they red under a
106+
* pattern-anchored strip, not under the missing fix.
107+
*
108+
* Leg B — `rest-server.ts` at pre-fix bytes: predicted exactly 1 red,
109+
* §7's longer-token case (the blanket regex eats `FORBIDDEN_BY_POLICY:`);
110+
* measured 1 red / 25 green across this file +
111+
* `rest-approvals-wire-codes.test.ts`, which stayed ALL green — the
112+
* anchored strip answers the well-formed idiom byte-identically.
79113
*/
80114

81115
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
@@ -372,13 +406,20 @@ describe('[#12975] the share family: convergence, and the two exits still carryi
372406
expect(dataAnswer.body.code).toBe('FORBIDDEN');
373407
});
374408

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.
409+
it('CONVERGENCE [#13095] — the two exits that used to ship the prefix no longer do', async () => {
410+
// ⚠️ MOVED DELIBERATELY. Until #13095 this case was titled
411+
// "MEASURED, NOT REPAIRED HERE" and pinned the OLD truth — both exits
412+
// below are reached through `resolveErrorResponse`'s declared-4xx
413+
// passthrough (checked BEFORE it delegates to `mapDataError`), which
414+
// the #12975 ruling did not name, so both still shipped
415+
// `FORBIDDEN: ${ZH}` while the by-id door had converged. That case
416+
// existed to red the day either exit converged, so the follow-up
417+
// would move it deliberately instead of discovering the divergence a
418+
// third time. This is that day: the 2026-08-31 maintainer ruling on
419+
// #13095 (option 1) put the same declared-code-anchored
420+
// `withoutDeclaredCodePrefix` in the passthrough arm, and the
421+
// record-share classified arm converges with it for free because it
422+
// re-dresses that same classification.
382423
//
383424
// (a) the record-share family's CLASSIFIED arm — a producer that
384425
// declared `{ code, status }` AND used the prefix idiom;
@@ -388,13 +429,99 @@ describe('[#12975] the share family: convergence, and the two exits still carryi
388429
boot({}, throwingShareService(sharingWriteRefusal())),
389430
'GET', SHARES, { params: { object: 'showcase_inquiry', id: 'rec1' } },
390431
);
391-
expect(classified.body.error.message).toBe(`FORBIDDEN: ${ZH}`);
432+
expect(classified.body.error.message).toBe(ZH);
433+
expect(classified.body.error.code).toBe('FORBIDDEN');
392434

393435
const bulk = await call(
394436
boot({ batchData: vi.fn().mockRejectedValue(sharingWriteRefusal()) }),
395437
'POST', `${COLLECTION}/batch`,
396438
{ params: { object: 'showcase_inquiry' }, body: { operation: 'update', records: [{ id: 'r1' }] } },
397439
);
398-
expect(bulk.body.error).toBe(`FORBIDDEN: ${ZH}`);
440+
expect(bulk.body.error).toBe(ZH);
441+
expect(bulk.body.code).toBe('FORBIDDEN');
442+
});
443+
});
444+
445+
// ---------------------------------------------------------------------------
446+
// §6 The passthrough arm's own controls — the strip stays ANCHORED (#13095)
447+
// ---------------------------------------------------------------------------
448+
449+
describe('[#13095] the bulk door strips by declared code, never by pattern', () => {
450+
const batchWith = (error: unknown) => call(
451+
boot({ batchData: vi.fn().mockRejectedValue(error) }),
452+
'POST', `${COLLECTION}/batch`,
453+
{ params: { object: 'showcase_inquiry' }, body: { operation: 'update', records: [{ id: 'r1' }] } },
454+
);
455+
456+
it('⭐ a declared 4xx with NO `code` KEEPS its prefix — the token is nowhere else', async () => {
457+
// §2's control, restated on the arm #13095 moved: `thrownCodeFields`
458+
// answers `{}` for a producer that named no code, so stripping here
459+
// would delete the only machine token in the response rather than
460+
// move it to its axis.
461+
const answer = await batchWith(thrown(`FORBIDDEN: ${ZH}`, { status: 403 }));
462+
expect(answer.status).toBe(403);
463+
expect(answer.body.error).toBe(`FORBIDDEN: ${ZH}`);
464+
expect('code' in answer.body).toBe(false);
465+
});
466+
467+
it('⭐ a prefix that does not name the declared code is left alone — driver prose stays', async () => {
468+
const answer = await batchWith(
469+
thrown('SQLITE_ERROR: no such table: showcase_inquiry', { code: 'FORBIDDEN', status: 400 }),
470+
);
471+
expect(answer.body.error).toBe('SQLITE_ERROR: no such table: showcase_inquiry');
472+
});
473+
474+
it('a message that is nothing but the prefix degrades to the generic sentence', async () => {
475+
const answer = await batchWith(thrown('FORBIDDEN:', { code: 'FORBIDDEN', status: 403 }));
476+
expect(answer.body.error).toBe('Request failed');
477+
expect(answer.body.code).toBe('FORBIDDEN');
478+
});
479+
});
480+
481+
// ---------------------------------------------------------------------------
482+
// §7 The approvals door — the third strip point, converged onto the anchor
483+
// ---------------------------------------------------------------------------
484+
485+
describe('[#13095] the approvals door strips the code it answers, never a blanket pattern', () => {
486+
const APPROVE = '/api/v1/approvals/requests/:id/approve';
487+
488+
const approveWith = (error: unknown) => {
489+
const protocol: any = {
490+
getDiscovery: vi.fn().mockResolvedValue({
491+
version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' },
492+
}),
493+
getMetaTypes: vi.fn().mockResolvedValue([]),
494+
getMetaItems: vi.fn().mockResolvedValue([]),
495+
findData: vi.fn().mockResolvedValue([]),
496+
};
497+
const rest = new RestServer(
498+
mockServer() as any, protocol, { api: { requireAuth: false } } as any,
499+
undefined, undefined, undefined, undefined, undefined, undefined,
500+
undefined, undefined,
501+
(async () => ({ decide: vi.fn().mockRejectedValue(error) })) as any,
502+
);
503+
(rest as any).resolveExecCtx = async () => ({ userId: 'u1' });
504+
rest.registerRoutes();
505+
return call(rest, 'POST', APPROVE, { params: { id: 'req_1' } });
506+
};
507+
508+
it('the well-formed idiom is unchanged: the answered code is stripped from the sentence', async () => {
509+
const answer = await approveWith(thrown(`FORBIDDEN: ${ZH}`));
510+
expect(answer.status).toBe(403);
511+
expect(answer.body.code).toBe('FORBIDDEN');
512+
expect(answer.body.error).toBe(ZH);
513+
});
514+
515+
it('⭐ a LONGER token sharing the matched spelling is NOT eaten — the blanket regex would have', async () => {
516+
// The distinguisher between the old `/^[A-Z_]+:\s*/` strip and the
517+
// anchored one: `/^FORBIDDEN/` matches this message, so the row
518+
// answers `code: 'FORBIDDEN'` — but the sentence opens with a
519+
// DIFFERENT token, which the wire carries nowhere else. The blanket
520+
// regex deleted it; the anchored strip removes only a duplicate of
521+
// the code being answered (#12975's rule, spread by #13095).
522+
const answer = await approveWith(thrown('FORBIDDEN_BY_POLICY: contact your administrator'));
523+
expect(answer.status).toBe(403);
524+
expect(answer.body.code).toBe('FORBIDDEN');
525+
expect(answer.body.error).toBe('FORBIDDEN_BY_POLICY: contact your administrator');
399526
});
400527
});

packages/rest/src/rest-server.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10360,12 +10360,23 @@ export class RestServer {
1036010360
// signals the verdict via message prefixes, the plugin's established
1036110361
// error idiom — this maps them onto HTTP. Returns true when handled.
1036210362
//
10363-
// [#8111] The prefix is a SERVER-INTERNAL service→REST derivation: it
10364-
// is stripped below and never reaches the wire, so no consumer can
10365-
// read it (censused at claim — the only in-repo `startsWith(CODE)`
10366-
// readers are this file's own route mappings plus one
10367-
// `plugin-approvals` check on an error it threw itself in-process).
10368-
// It therefore stays exactly as it is; only the response SHAPE moved.
10363+
// [#8111] The prefix is a SERVER-INTERNAL service→REST derivation.
10364+
// ⚠️ [#13095] This comment used to claim the MECHANISM guaranteed
10365+
// that: "it is stripped below and never reaches the wire". That was
10366+
// true of the prefix-idiom arm it was written about and FALSE for the
10367+
// classified limb #11683 added beside it, which re-dresses
10368+
// `resolveErrorResponse`'s answer — an answer that shipped the prefix
10369+
// inside the sentence until the 2026-08-31 ruling converged that arm
10370+
// onto the same declared-code-anchored strip. Even now the strip is
10371+
// ANCHORED (it removes only a prefix restating the declared code), so
10372+
// "never reaches the wire" is not a mechanism anyone may lean on.
10373+
// What holds instead is MEASURED, not guaranteed: no consumer
10374+
// branches on the prefix in the wire `error` text — censused at
10375+
// #8111's claim and re-censused 2026-09-01 (objectstack + objectui:
10376+
// zero wire readers; every `startsWith(CODE)` hit is this file's own
10377+
// route mappings or an in-process producer-side check; `cloud` was
10378+
// not reachable and is NOT measured). The prefix idiom itself stays
10379+
// exactly as it is; #8111 moved only the response SHAPE.
1036910380
//
1037010381
// [#11683] …and it stays exactly as it is here too. What moved is that
1037110382
// the prefix read is no longer the FIRST question, and no longer the
@@ -11459,7 +11470,18 @@ export class RestServer {
1145911470
];
1146011471
for (const [re, status, code] of mapping) {
1146111472
if (re.test(msg)) {
11462-
res.status(status).json({ code, error: msg.replace(/^[A-Z_]+:\s*/, '') });
11473+
// [#13095] The strip is anchored to the CODE this row just
11474+
// answered — the same declared-code anchoring
11475+
// `withoutDeclaredCodePrefix` (error-response.ts) and
11476+
// `respondSharingError`'s prefix arm apply, converged here
11477+
// by the 2026-08-31 ruling. The blanket
11478+
// SCREAMING_SNAKE-colon regex that used to sit here
11479+
// (`/^[A-Z_]+:\s*/`) is exactly the shape #12975 rejected:
11480+
// it could eat a token the wire carries nowhere else (a
11481+
// message opening with a DIFFERENT capitalised word and a
11482+
// colon), where the anchored form can only ever remove a
11483+
// duplicate of the `code` already on the wire.
11484+
res.status(status).json({ code, error: msg.replace(new RegExp(`^${code}:\\s*`), '') });
1146311485
return true;
1146411486
}
1146511487
}

0 commit comments

Comments
 (0)