Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .changeset/rest-passthrough-strips-declared-code-prefix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
"@objectstack/rest": minor
---

fix(rest): every `/data` exit and the approvals door now hand the caller the human half of a declared-code-prefixed message (#13095)

The 2026-08-29 ruling on #12975 made the by-id `/data` door strip the
ADR-0111 `CODE:` prefix from the human-readable `error` string — one
envelope semantics: `error` is human language, `code` is the machine token.
`resolveErrorResponse`'s declared-4xx `passThroughStatus` arm is a second
declared-4xx arm the ruling did not name, checked BEFORE the door it
delegates to — so the same refusal read two ways depending on which route
caught it: `PATCH /data/:object/:id` answered the bare localized sentence
while `POST /data/:object/batch` (and every bulk/clone exit reporting
through `handleRouteError`) and the record-share classified arm still
shipped `FORBIDDEN: <sentence>`. Maintainer ruling 2026-08-31 (option 1):
converge them.

On-wire changes, all subtractive on message text only, all 4xx, statuses
and `code`/`declaredCode` fields untouched:

- `resolveErrorResponse`'s declared-4xx arm now applies the same
declared-code-anchored strip (`withoutDeclaredCodePrefix`) the by-id door
applies: a message opening with the producer's own declared `code`
followed by a colon loses that prefix — and only that prefix. A message
that is nothing but the prefix degrades to `Request failed`. This
converges the `/data` batch/createMany/updateMany/deleteMany/clone exits
and — because the record-share classified arm re-dresses the same
classification — `GET/POST/DELETE /data/:object/:id/shares*` with them.
- The approvals door's prefix strip is now anchored to the code the row
answers instead of the blanket `/^[A-Z_]+:\s*/` regex (the shape #12975
rejected): a sentence opening with a DIFFERENT `SCREAMING_SNAKE:` token
than the answered code is no longer eaten.

Not moved, deliberately: a declared 4xx with NO `code` keeps its message
verbatim (the token is nowhere else on the wire); a prefix that does not
restate the declared code is left alone; declared-5xx prose withholding is
unchanged; the share family's bare-`Error` prefix-idiom arm already
stripped and is untouched; an empty-string message through the passthrough
still ships as itself (that TYPE-keyed degrade is a standing pin this
ruling did not move).

**Migration.** Consumers that parsed the `CODE:` prefix off the front of
`error` (flat `/data` doors) or `error.message` (nested record-share
envelope) on these routes must read the `code` field instead — it has
carried the same token all along, with unregistered spellings demoted to
the `declaredCode` sibling (#9232). The consumer census behind this change
covered `objectstack`, `objectui` and `hotcrm` (re-run 2026-09-01, each
scope with a positive control) and found zero consumers branching on the
prefix. `objectstack-ai/cloud` was NOT MEASURED — it was unreachable from
the implementing session — and is deliberately not reported as clean: the
zero above is a statement about the three repos named, not about every
deployment. An operator whose code parses the leading token off these
routes' error text should locate and update those reads before upgrading.
Per #13347's precedent an error-envelope shape change ships as `minor`
with this note.
41 changes: 37 additions & 4 deletions packages/rest/src/error-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1762,11 +1762,44 @@ function resolveErrorResponse(error: any, object?: string): { status: number; bo
// means moving the STATUS this arm decided, which is the contract
// question this card was fenced away from — filed separately.
const businessMessage = sandboxBusinessMessage(error);
const safeMsg = businessMessage !== undefined
? truncateClientMessage(businessMessage)
: typeof error.message !== 'string'
// [#13095] The sentence this arm hands the caller is the HUMAN half
// only — the same #12975 rule `classifyDataError`'s declared-4xx arm
// applies, spread here by the 2026-08-31 maintainer ruling (option 1:
// one envelope semantics on every `/data` exit). This arm is checked
// BEFORE it delegates to `mapDataError`, so every route that reports
// through `handleRouteError` / `sendThrownError` (batch, createMany,
// updateMany, deleteMany, clone, and the record-share classified arm,
// which re-dresses this very answer through `classifiedRefusalAnswer`)
// was still shipping the ADR-0111 `CODE:` prefix the by-id door had
// stopped shipping — one refusal, two readings, decided by which
// route caught it.
//
// Anchored to the DECLARED code ({@link withoutDeclaredCodePrefix} —
// ⛔ never a SCREAMING_SNAKE pattern; that function's docblock carries
// the safety argument), and run BEFORE the bound for #12975's reason:
// the prefix is not text addressed to the caller, so it must not
// spend the caller's #5423 budget. A message that is nothing but the
// prefix has no human half to ship and degrades to 'Request failed',
// the sibling arm's rule travelling WITH the strip.
//
// ⛔ What deliberately does NOT converge here: a genuinely EMPTY
// string message still ships as itself. This arm's degrade is keyed
// on the TYPE, unlike `classifyDataError`'s sibling which also checks
// length — a standing pin (`rest-hook-refusal-message-parity.test.ts`)
// this card's ruling did not authorise moving. The 'Request failed'
// limb below therefore fires only when the STRIP emptied a non-empty
// message, never for a message that arrived empty.
const addressed = businessMessage !== undefined
? businessMessage
: typeof error.message === 'string' ? error.message : undefined;
const authored = addressed === undefined
? undefined
: withoutDeclaredCodePrefix(addressed, error);
const safeMsg = authored === undefined
? 'Request failed'
: authored.length === 0 && addressed !== undefined && addressed.length > 0
? 'Request failed'
: truncateClientMessage(error.message);
: truncateClientMessage(authored);
// [#9232] Narrowed, same as the three arms above.
return withDeclaredUserMessage(error, {
status: error.status,
Expand Down
145 changes: 136 additions & 9 deletions packages/rest/src/rest-data-door-code-prefix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,40 @@
* 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.
*
* ---------------------------------------------------------------------------
* [#13095] §5's pin MOVED — 2026-08-31 maintainer ruling, option 1
* ---------------------------------------------------------------------------
* Everything above this line is the #12975-era record, kept as history.
* #13095 spread the same declared-code-anchored strip to
* `resolveErrorResponse`'s declared-4xx passthrough (and, through the one
* classification the share family re-dresses, its classified arm), and
* converged the approvals door's blanket `^[A-Z_]+:\s*` regex strip onto the
* code the row answers — so §5's "MEASURED, NOT REPAIRED HERE" case, written
* to red the day either exit converged, was MOVED deliberately to the
* CONVERGENCE pin it announced, and §6/§7 pin the moved arms' own anchoring
* controls.
*
* #13095 ablation (two legs, predictions written before running; mutation
* AND restore each proven on disk by blob-hash equality against the named
* rev plus single-occurrence anchor counts both ways; no rebuild between
* legs — every subject is reached by RELATIVE in-package imports vitest
* transforms from source, the same argument as above):
*
* Leg A — `error-response.ts` at pre-fix bytes: predicted exactly 2 red,
* §5's CONVERGENCE pin and §6's nothing-but-prefix case; measured 2 red /
* 50 green across this file + `rest-hook-refusal-message-parity.test.ts`,
* which stayed ALL green — the empty-string TYPE-keyed degrade pinned
* there (deliberately NOT converged; that pin was not this ruling's to
* move) measurably did not move. §6's no-code and non-matching-prefix
* cases are controls and stayed green on BOTH sides: they red under a
* pattern-anchored strip, not under the missing fix.
*
* Leg B — `rest-server.ts` at pre-fix bytes: predicted exactly 1 red,
* §7's longer-token case (the blanket regex eats `FORBIDDEN_BY_POLICY:`);
* measured 1 red / 25 green across this file +
* `rest-approvals-wire-codes.test.ts`, which stayed ALL green — the
* anchored strip answers the well-formed idiom byte-identically.
*/

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

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}`);
expect(bulk.body.error).toBe(ZH);
expect(bulk.body.code).toBe('FORBIDDEN');
});
});

// ---------------------------------------------------------------------------
// §6 The passthrough arm's own controls — the strip stays ANCHORED (#13095)
// ---------------------------------------------------------------------------

describe('[#13095] the bulk door strips by declared code, never by pattern', () => {
const batchWith = (error: unknown) => call(
boot({ batchData: vi.fn().mockRejectedValue(error) }),
'POST', `${COLLECTION}/batch`,
{ params: { object: 'showcase_inquiry' }, body: { operation: 'update', records: [{ id: 'r1' }] } },
);

it('⭐ a declared 4xx with NO `code` KEEPS its prefix — the token is nowhere else', async () => {
// §2's control, restated on the arm #13095 moved: `thrownCodeFields`
// answers `{}` for a producer that named no code, so stripping here
// would delete the only machine token in the response rather than
// move it to its axis.
const answer = await batchWith(thrown(`FORBIDDEN: ${ZH}`, { status: 403 }));
expect(answer.status).toBe(403);
expect(answer.body.error).toBe(`FORBIDDEN: ${ZH}`);
expect('code' in answer.body).toBe(false);
});

it('⭐ a prefix that does not name the declared code is left alone — driver prose stays', async () => {
const answer = await batchWith(
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 message that is nothing but the prefix degrades to the generic sentence', async () => {
const answer = await batchWith(thrown('FORBIDDEN:', { code: 'FORBIDDEN', status: 403 }));
expect(answer.body.error).toBe('Request failed');
expect(answer.body.code).toBe('FORBIDDEN');
});
});

// ---------------------------------------------------------------------------
// §7 The approvals door — the third strip point, converged onto the anchor
// ---------------------------------------------------------------------------

describe('[#13095] the approvals door strips the code it answers, never a blanket pattern', () => {
const APPROVE = '/api/v1/approvals/requests/:id/approve';

const approveWith = (error: unknown) => {
const protocol: any = {
getDiscovery: vi.fn().mockResolvedValue({
version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' },
}),
getMetaTypes: vi.fn().mockResolvedValue([]),
getMetaItems: vi.fn().mockResolvedValue([]),
findData: vi.fn().mockResolvedValue([]),
};
const rest = new RestServer(
mockServer() as any, protocol, { api: { requireAuth: false } } as any,
undefined, undefined, undefined, undefined, undefined, undefined,
undefined, undefined,
(async () => ({ decide: vi.fn().mockRejectedValue(error) })) as any,
);
(rest as any).resolveExecCtx = async () => ({ userId: 'u1' });
rest.registerRoutes();
return call(rest, 'POST', APPROVE, { params: { id: 'req_1' } });
};

it('the well-formed idiom is unchanged: the answered code is stripped from the sentence', async () => {
const answer = await approveWith(thrown(`FORBIDDEN: ${ZH}`));
expect(answer.status).toBe(403);
expect(answer.body.code).toBe('FORBIDDEN');
expect(answer.body.error).toBe(ZH);
});

it('⭐ a LONGER token sharing the matched spelling is NOT eaten — the blanket regex would have', async () => {
// The distinguisher between the old `/^[A-Z_]+:\s*/` strip and the
// anchored one: `/^FORBIDDEN/` matches this message, so the row
// answers `code: 'FORBIDDEN'` — but the sentence opens with a
// DIFFERENT token, which the wire carries nowhere else. The blanket
// regex deleted it; the anchored strip removes only a duplicate of
// the code being answered (#12975's rule, spread by #13095).
const answer = await approveWith(thrown('FORBIDDEN_BY_POLICY: contact your administrator'));
expect(answer.status).toBe(403);
expect(answer.body.code).toBe('FORBIDDEN');
expect(answer.body.error).toBe('FORBIDDEN_BY_POLICY: contact your administrator');
});
});
36 changes: 29 additions & 7 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10304,12 +10304,23 @@ export class RestServer {
// signals the verdict via message prefixes, the plugin's established
// error idiom — this maps them onto HTTP. Returns true when handled.
//
// [#8111] The prefix is a SERVER-INTERNAL service→REST derivation: it
// is stripped below and never reaches the wire, so no consumer can
// read it (censused at claim — the only in-repo `startsWith(CODE)`
// readers are this file's own route mappings plus one
// `plugin-approvals` check on an error it threw itself in-process).
// It therefore stays exactly as it is; only the response SHAPE moved.
// [#8111] The prefix is a SERVER-INTERNAL service→REST derivation.
// ⚠️ [#13095] This comment used to claim the MECHANISM guaranteed
// that: "it is stripped below and never reaches the wire". That was
// true of the prefix-idiom arm it was written about and FALSE for the
// classified limb #11683 added beside it, which re-dresses
// `resolveErrorResponse`'s answer — an answer that shipped the prefix
// inside the sentence until the 2026-08-31 ruling converged that arm
// onto the same declared-code-anchored strip. Even now the strip is
// ANCHORED (it removes only a prefix restating the declared code), so
// "never reaches the wire" is not a mechanism anyone may lean on.
// What holds instead is MEASURED, not guaranteed: no consumer
// branches on the prefix in the wire `error` text — censused at
// #8111's claim and re-censused 2026-09-01 (objectstack + objectui:
// zero wire readers; every `startsWith(CODE)` hit is this file's own
// route mappings or an in-process producer-side check; `cloud` was
// not reachable and is NOT measured). The prefix idiom itself stays
// exactly as it is; #8111 moved only the response SHAPE.
//
// [#11683] …and it stays exactly as it is here too. What moved is that
// the prefix read is no longer the FIRST question, and no longer the
Expand Down Expand Up @@ -11403,7 +11414,18 @@ export class RestServer {
];
for (const [re, status, code] of mapping) {
if (re.test(msg)) {
res.status(status).json({ code, error: msg.replace(/^[A-Z_]+:\s*/, '') });
// [#13095] The strip is anchored to the CODE this row just
// answered — the same declared-code anchoring
// `withoutDeclaredCodePrefix` (error-response.ts) and
// `respondSharingError`'s prefix arm apply, converged here
// by the 2026-08-31 ruling. The blanket
// SCREAMING_SNAKE-colon regex that used to sit here
// (`/^[A-Z_]+:\s*/`) is exactly the shape #12975 rejected:
// it could eat a token the wire carries nowhere else (a
// message opening with a DIFFERENT capitalised word and a
// colon), where the anchored form can only ever remove a
// duplicate of the `code` already on the wire.
res.status(status).json({ code, error: msg.replace(new RegExp(`^${code}:\\s*`), '') });
return true;
}
}
Expand Down
Loading
Loading