From 4633fd31a2a412eb0fcaf9e69fe7c5e1c4aa1d46 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 04:54:02 +0000 Subject: [PATCH 1/4] 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 Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk --- packages/rest/src/error-response.ts | 41 ++++++- .../src/rest-data-door-code-prefix.test.ts | 111 ++++++++++++++++-- packages/rest/src/rest-server.ts | 36 ++++-- packages/rest/src/sharing-envelope.test.ts | 17 ++- 4 files changed, 180 insertions(+), 25 deletions(-) diff --git a/packages/rest/src/error-response.ts b/packages/rest/src/error-response.ts index 25ed097978..424c28ee76 100644 --- a/packages/rest/src/error-response.ts +++ b/packages/rest/src/error-response.ts @@ -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, 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 976d8b3741..292da3fdcd 100644 --- a/packages/rest/src/rest-data-door-code-prefix.test.ts +++ b/packages/rest/src/rest-data-door-code-prefix.test.ts @@ -372,13 +372,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; @@ -388,13 +395,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'); }); }); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 902319cdf0..9d82c08ec8 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -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 @@ -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; } } diff --git a/packages/rest/src/sharing-envelope.test.ts b/packages/rest/src/sharing-envelope.test.ts index 7b0ebbd79f..79025eb84b 100644 --- a/packages/rest/src/sharing-envelope.test.ts +++ b/packages/rest/src/sharing-envelope.test.ts @@ -24,11 +24,18 @@ * * `respondSharingError` recovers the verdict by parsing the service's message * text (`msg.startsWith(CODE)`) and then STRIPS that prefix before answering. - * The prefix is a server-internal service→REST derivation that never reaches - * the wire, so no consumer can read it — censused at claim. It is therefore - * untouched here: this card moved the response SHAPE only. `stripsThePrefix` - * below pins that the stripping still happens, so a future reader does not - * "restore" a prefix the wire never carried. + * The prefix is a server-internal service→REST derivation. ⚠️ [#13095] This + * docblock used to say the mechanism guaranteed that ("never reaches the + * wire") — false for the CLASSIFIED limb #11683 later added beside this arm, + * which re-dressed `resolveErrorResponse`'s answer prefix and all until the + * 2026-08-31 ruling converged that arm onto the same declared-code-anchored + * strip. What holds is MEASURED, not guaranteed: no consumer branches on the + * prefix in the wire text (censused at #8111's claim; re-censused 2026-09-01 + * over objectstack + objectui with zero wire readers; `cloud` unreachable, + * NOT measured). The prefix idiom itself is untouched here: #8111 moved the + * response SHAPE only. `stripsThePrefix` below pins that the stripping still + * happens, so a future reader does not "restore" a prefix the wire does not + * carry on this arm. * * ## What these cases assert, and why not `toThrow` * From c5e3a06dc167784a66b776c1f970dc6931bcb2ff Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 04:56:53 +0000 Subject: [PATCH 2/4] docs(rest): record the #13095 ablation in the pin file header; changeset (minor + migration note) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk --- ...passthrough-strips-declared-code-prefix.md | 50 +++++++++++++++++++ .../src/rest-data-door-code-prefix.test.ts | 34 +++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 .changeset/rest-passthrough-strips-declared-code-prefix.md diff --git a/.changeset/rest-passthrough-strips-declared-code-prefix.md b/.changeset/rest-passthrough-strips-declared-code-prefix.md new file mode 100644 index 0000000000..35abec6055 --- /dev/null +++ b/.changeset/rest-passthrough-strips-declared-code-prefix.md @@ -0,0 +1,50 @@ +--- +"@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: `. 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). A cross-repo census (objectstack, +objectui, hotcrm; re-run 2026-09-01) found zero consumers branching on the +prefix; per #13347's precedent an error-envelope shape change ships as +`minor` with this note. 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 292da3fdcd..92fc7465da 100644 --- a/packages/rest/src/rest-data-door-code-prefix.test.ts +++ b/packages/rest/src/rest-data-door-code-prefix.test.ts @@ -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*/` 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'; From 4ed81ba8b374b172172c049e45c4a4d5a191b358 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 05:07:10 +0000 Subject: [PATCH 3/4] 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 Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk --- packages/rest/src/rest-data-door-code-prefix.test.ts | 2 +- 1 file changed, 1 insertion(+), 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 92fc7465da..6caee424b9 100644 --- a/packages/rest/src/rest-data-door-code-prefix.test.ts +++ b/packages/rest/src/rest-data-door-code-prefix.test.ts @@ -84,7 +84,7 @@ * #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*/` strip onto the + * 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 From 209965648d4871bf2d20df5ccf4fc9357deae682 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 06:20:42 +0000 Subject: [PATCH 4/4] 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 Claude-Session: https://claude.ai/code/session_01UngCYXF98BVpYA9hfz6NYk --- ...rest-passthrough-strips-declared-code-prefix.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.changeset/rest-passthrough-strips-declared-code-prefix.md b/.changeset/rest-passthrough-strips-declared-code-prefix.md index 35abec6055..39aca7582a 100644 --- a/.changeset/rest-passthrough-strips-declared-code-prefix.md +++ b/.changeset/rest-passthrough-strips-declared-code-prefix.md @@ -44,7 +44,13 @@ ruling did not move). `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). A cross-repo census (objectstack, -objectui, hotcrm; re-run 2026-09-01) found zero consumers branching on the -prefix; per #13347's precedent an error-envelope shape change ships as -`minor` with this note. +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.