From 8dbb28286b3980fb4fbb5466d2997c04badb2aaf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 02:53:48 +0000 Subject: [PATCH 1/2] fix(client): retire the pre-#3842 parking-spot read; category/retryable read the declared spot (#4006, #4007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - err.code no longer falls back to error.details.code. The "newer SDK, older server" pairing that read served is not a supported deployment (SDK and server ship as a changesets fixed group), and ADR-0112 batches 1-2 renamed the code VALUES anyway — a code dug out of an old server's parking spot matches no branch written against the current catalog. Location-compat without value-compat protects nothing (#4007 maintainer ruling; ADR-0112 D5 amended in place). - err.category / err.retryable now read from inside error, where ApiErrorSchema declares them; the old top-level read yielded undefined against every conformant server (ADR-0112 D9b, #4006). - Tests: the "older server, newer SDK" pin flips to pin the retirement; two new cases cover the D9b reads (present and absent). - Docs: client-sdk.mdx / wire-format.mdx stop describing the retired fallback; error-catalog.mdx's actions rejection example corrected to the real wire (envelope assembly promotes the thrown code into error.code — details carries fields only); v17.mdx notes the retirement and uses post-rename spelling in the derived-map example. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018u8vDQLWPJxBWQESznDpSj --- .../client-retires-parking-spot-read.md | 5 ++ content/docs/api/client-sdk.mdx | 4 +- content/docs/api/error-catalog.mdx | 6 +- content/docs/api/wire-format.mdx | 4 +- content/docs/releases/v17.mdx | 8 ++- .../0112-error-code-vocabulary-and-ledger.md | 2 + packages/client/src/client.test.ts | 58 +++++++++++++++---- packages/client/src/index.ts | 27 +++++---- 8 files changed, 85 insertions(+), 29 deletions(-) create mode 100644 .changeset/client-retires-parking-spot-read.md diff --git a/.changeset/client-retires-parking-spot-read.md b/.changeset/client-retires-parking-spot-read.md new file mode 100644 index 0000000000..1c8ac586e4 --- /dev/null +++ b/.changeset/client-retires-parking-spot-read.md @@ -0,0 +1,5 @@ +--- +'@objectstack/client': minor +--- + +`err.code` no longer falls back to the pre-#3842 parking spot (`error.details.code`). The "newer SDK, older server" pairing that read served is not a supported deployment (SDK and server ship as one fixed release group), and the ADR-0112 batch-1 rename changed the code values anyway — a code dug out of an old server's parking spot would match no branch written against the current catalog (#4007). `err.category` / `err.retryable` are now read from inside `error`, where `ApiErrorSchema` declares them; the old top-level read yielded `undefined` against every conformant server (#4006). diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index 614a8978e8..481214fcc2 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -526,8 +526,8 @@ which server surface answered: the REST server replies with a flat `{ success, error: { code, message, httpStatus, details } }` body, and the client normalizes both before throwing. (Dispatcher bodies from servers older than #3842 put the status in `error.code` and the semantic code in -`error.details.code`; the client still reads those, so an SDK newer than the -server it talks to behaves the same.) +`error.details.code`; that fallback read was retired in #4007 — SDK and server +ship as one release group, so that pairing is not a supported deployment.) ### Per-field validation errors diff --git a/content/docs/api/error-catalog.mdx b/content/docs/api/error-catalog.mdx index 8b07f95e70..bf34d99c17 100644 --- a/content/docs/api/error-catalog.mdx +++ b/content/docs/api/error-catalog.mdx @@ -393,8 +393,10 @@ A rejection and a crash are told apart by the thrown error: a plain `throw new Error(msg)`, a sandboxed body's deliberate throw, or a `ValidationError` is a rejection (400); a `TypeError` / `ReferenceError` / a driver's own error class is a crash (500). An error carrying its own `status` -is served with it. A validation rejection carries `details.code: -'VALIDATION_FAILED'` and `details.fields[]`, identical to `/data`. +is served with it. A validation rejection carries `error.code: +'VALIDATION_FAILED'` with the per-field list in `error.details.fields[]` — the +same code and field entries `/data` reports (the envelope assembly promotes a +thrown error's `code` into `error.code`; `details` is context only). ```typescript const res = await fetch(`/api/v1/actions/${object}/${action}`, { /* … */ }); diff --git a/content/docs/api/wire-format.mdx b/content/docs/api/wire-format.mdx index 9aa95e8fae..cd71343ae5 100644 --- a/content/docs/api/wire-format.mdx +++ b/content/docs/api/wire-format.mdx @@ -461,8 +461,8 @@ status. Before [#3842](https://github.com/objectstack-ai/objectstack/issues/3842) this envelope put the HTTP status in `error.code` and the real code in `error.details.code`, `error.details.type` or `error.type`. `ObjectStackClient` -reads all of them, so an SDK newer than the server it talks to is unaffected; -code reading these bodies directly should move to `error.code`. +no longer reads those legacy spots (#4007 — servers and SDKs ship as one +release group); any code reading them directly should move to `error.code`. --- diff --git a/content/docs/releases/v17.mdx b/content/docs/releases/v17.mdx index 01a4b5ccd5..7b3c1ec016 100644 --- a/content/docs/releases/v17.mdx +++ b/content/docs/releases/v17.mdx @@ -553,14 +553,16 @@ somewhere else and did, in three different somewhere-elses: `error.details.code` `body.error.details?.code ?? body.error.type` read with `body.error.code`, and a `body.error.code` read (for the status) with `body.error.httpStatus`. - **SDK callers need no change.** `ObjectStackClient` already normalised this — - `err.code` semantic, `err.httpStatus` numeric — and still reads the old shape, - so a client newer than its server behaves identically. + `err.code` semantic, `err.httpStatus` numeric. The old-shape fallback read was + retired later in this same window (#4007): SDK and server ship on one release + train, and the ADR-0112 rename changed the code values a dug-out code would + need to match. - **No code was renamed.** `PERMISSION_DENIED`, `ROUTE_NOT_FOUND`, `PASSWORD_EXPIRED`, `PROJECT_MEMBERSHIP_REQUIRED`, `VALIDATION_FAILED` and `unauthenticated` all reach the wire spelled exactly as before; only their field changed. Reconciling the platform's two code vocabularies is #3841. A branch with no code of its own now derives a `StandardErrorCode` from the - status (`403` → `permission_denied`), spelled in one map in the spec. + status (`403` → `PERMISSION_DENIED`, post-rename spelling), spelled in one map in the spec. - **Spec:** `ApiErrorSchema` gains optional `httpStatus`; `StandardErrorCode` gains `method_not_allowed` and `precondition_required` (both additive). `DispatcherErrorCode` changes members from `'404' | '405' | '501' | '503'` to diff --git a/docs/adr/0112-error-code-vocabulary-and-ledger.md b/docs/adr/0112-error-code-vocabulary-and-ledger.md index 87fa7aa618..881e1a0e94 100644 --- a/docs/adr/0112-error-code-vocabulary-and-ledger.md +++ b/docs/adr/0112-error-code-vocabulary-and-ledger.md @@ -90,6 +90,8 @@ Nine rulings, D1–D9. **D5 — One location, eventually: `error.code` carries the semantic code.** Target end-state, recorded here so the follow-ups have a fixed destination: the HTTP status lives on the transport and (optionally) `error.httpStatus`; `error.code` is always the semantic string; `error.details.code` and `error.type` are retired as code carriers. The dispatcher-occupation fix (#3689 sibling) and `ROUTE_NOT_FOUND`-in-`type` retirement land as follow-ups (Rollout, batch 3). The client's three-location probe is deleted only after both. +> **Amendment (2026-07-30, [#4007](https://github.com/objectstack-ai/objectstack/issues/4007)) — ruled and done.** With batch 3 landed, the client's parking-spot read (`error.details.code`) is deleted: SDK and server ship as a changesets fixed group, so the "newer SDK, older server" pairing it served is not a supported deployment — and batches 1–2 renamed the code *values* anyway, so a code dug out of an old server's parking spot would match no branch written against the current catalog; location-compat without value-compat protects nothing. The client's two remaining reads are the two *live* envelopes' declared spots (flat top-level `code`, wrapped `error.code`) — a present-tense fact, not a fallback chain; retiring the flat shape itself belongs to the envelope-convergence line (#3843 family), not this ADR. The D9b nesting fix (`category`/`retryable`, [#4006](https://github.com/objectstack-ai/objectstack/issues/4006)) landed in the same change. + **D6 — Field-level codes are explicitly a separate vocabulary, and the schema stops lying meanwhile.** `FieldErrorSchema.code` widens from `StandardErrorCode` to `z.string()` with a banner comment pointing at the follow-up issue. Widening is honest (emitters never complied) and prevents this ADR's rename from silently claiming field-level compliance it does not deliver. The field vocabulary (validator codes, import codes, Zod-passthrough, the `fields` vs `fieldErrors` naming, and a schema for the wire array) gets its own issue and, if the decision is non-obvious, its own ADR. **D7 — `error-catalog.mdx` is generated from the spec.** The hand-written catalog is how docs and wire drifted apart in the first place (`docs/audits/2026-06-handwritten-docs-accuracy-followups.md:230` catalogues the casing drift; `:106` found a documented-only vocabulary with zero emitters). Generated docs are the only docs that cannot lie — and for AI authors, docs *are* context. diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 4eb38442dc..397bd576eb 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -1481,9 +1481,9 @@ describe('HTTP error shaping — envelope normalisation', () => { /** * What the runtime dispatcher put on the wire between #3918 and #3842: the * HTTP status in `code`, the real code parked in `details`. Kept as a - * fixture because the SDK must keep reading it — a client build talks to - * whatever server version it is pointed at, so this is a live legacy-server - * shape, not a stale test. + * fixture to PIN the #4007 retirement — the SDK deliberately no longer + * digs this shape's parking spot (one release train; ADR-0112 renamed the + * code values, so a dug-out code matches no current branch anyway). */ const WRAPPED_LEGACY = { success: false, @@ -1524,29 +1524,36 @@ describe('HTTP error shaping — envelope normalisation', () => { expect(caught.httpStatus).toBe(400); }); - it('reads the pre-#3842 wrapped shape identically (older server, newer SDK)', async () => { - // #3842 cured this at the producer, but an SDK build talks to whatever - // server it is pointed at, so the `details.code` hop must keep working. + it('no longer digs the pre-#3842 parking spot (#4007: retired pairing)', async () => { + // #3842 cured this at the producer; #4007 retired the client-side dig. + // SDK and server ship as a changesets fixed group, and ADR-0112 + // batches 1–2 renamed the code VALUES — a code dug out of an old + // server's `details.code` would not match any branch written against + // the current catalog, so the read protected nothing. const { client } = createMockClient(WRAPPED_LEGACY, 400); const caught: any = await client.data.delete('pm_base', 'rec_1').catch((e) => e); - expect(caught.code).toBe('VALIDATION_FAILED'); + expect(caught.code).toBeUndefined(); + // Everything else about the legacy shape still normalises: `fields` + // lives at `error.details.fields` in the CURRENT wrapped envelope too, + // and the message/status reads are shape-independent. expect(caught.fields).toEqual(FIELDS); expect(caught.httpStatus).toBe(400); + expect(caught.message).toBe('Validation failed'); }); - it('reports the same code from all three envelopes for the same failure', async () => { + it('reports the same code from both live envelopes for the same failure', async () => { // The asymmetry #3636 → #3675 → #3689 → #3842 has been closing: which // surface answered must stop being observable to the caller. const codes = await Promise.all( - [FLAT, WRAPPED, WRAPPED_LEGACY].map((body) => + [FLAT, WRAPPED].map((body) => createMockClient(body, 400).client.data .delete('pm_base', 'rec_1') .catch((e: any) => e.code), ), ); - expect(codes).toEqual(['VALIDATION_FAILED', 'VALIDATION_FAILED', 'VALIDATION_FAILED']); + expect(codes).toEqual(['VALIDATION_FAILED', 'VALIDATION_FAILED']); }); it('exposes `fields[]` at the same place for BOTH envelopes', async () => { @@ -1569,6 +1576,37 @@ describe('HTTP error shaping — envelope normalisation', () => { expect(caught.code).toBe('PERMISSION_DENIED'); }); + it('reads `category` / `retryable` from inside `error`, where the contract declares them (#4006)', async () => { + // These two used to be read from the body TOP level, where no envelope + // ever put them — `err.category` / `err.retryable` were `undefined` + // against every conformant server (ADR-0112 D9b). + const { client } = createMockClient( + { + success: false, + error: { + code: 'SERVICE_UNAVAILABLE', + message: 'engine still booting', + httpStatus: 503, + category: 'availability', + retryable: true, + }, + }, + 503, + ); + const caught: any = await client.data.delete('pm_base', 'rec_1').catch((e) => e); + + expect(caught.category).toBe('availability'); + expect(caught.retryable).toBe(true); + }); + + it('leaves `category` / `retryable` unset when the server sent neither', async () => { + const { client } = createMockClient(FLAT, 400); + const caught: any = await client.data.delete('pm_base', 'rec_1').catch((e) => e); + + expect(caught.category).toBeUndefined(); + expect(caught.retryable).toBeUndefined(); + }); + it('never reports a numeric code, even with no details to fall back on', async () => { // A pre-#3918 dispatcher body: wrapped, but no `details` at all. const { client } = createMockClient( diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 6eb82fd612..6cd168540c 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -4455,20 +4455,23 @@ export class ObjectStackClient { // semantic string on both surfaces now, and the number has its own // `error.httpStatus`. // - // The `details.code` hop stays in the chain regardless, and is NOT debt: - // an SDK build talks to whatever server version it is pointed at, so a - // client newer than its server must still find the code where that - // server put it. Same reasoning as the console's two-dialect read in - // objectui#2869. It is now a legacy-server fallback rather than the - // primary path, so the order below is unchanged but its meaning is. + // Between #3842 and #4007 a third read sat in this chain, digging the + // pre-#3842 parking spot (`error.details.code`) for "newer SDK, older + // server" pairings. #4007 retired it: SDK and server ship on one + // release train (a changesets fixed group), so that pairing is not a + // supported deployment — and ADR-0112 batches 1–2 renamed the code + // VALUES anyway, so a code dug out of an old server's parking spot + // would no longer match any branch written against the current + // catalog. Location-compat without value-compat protects nothing. // // So: `err.code` is always the semantic STRING (the numeric status is on // `err.httpStatus`, where it always was), and `err.fields` is always the - // per-field list when the server sent one. + // per-field list when the server sent one. The two reads below are the + // two LIVE envelopes' declared spots, not a fallback chain — the flat + // shape's retirement belongs to the envelope-convergence line (#3843). const asSemanticCode = (v: unknown) => (typeof v === 'string' && v ? v : undefined); const errorCode = asSemanticCode(errorBody?.code) - ?? asSemanticCode(errorBody?.error?.details?.code) ?? asSemanticCode(errorBody?.error?.code); const fieldErrors = Array.isArray(errorBody?.fields) ? errorBody.fields @@ -4483,9 +4486,13 @@ export class ObjectStackClient { // Attach error details for programmatic access error.code = errorCode; - error.category = errorBody?.category; + // `category` / `retryable` are declared INSIDE `error` (ApiErrorSchema); + // the flat envelope never carries them and nothing ever emitted them at + // the body top level, so the old top-level read returned `undefined` + // against every conformant server (ADR-0112 D9b, #4006). + error.category = errorBody?.error?.category; error.httpStatus = res.status; - error.retryable = errorBody?.retryable; + error.retryable = errorBody?.error?.retryable; // Prefer the wrapped envelope's own `details` over the whole body. The // flat envelope has no top-level `details`, so it keeps falling through // to `errorBody` exactly as before — only the wrapped shape changes, From 9bd1568521edc421883647de75c1287eeb2ada1b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 02:58:12 +0000 Subject: [PATCH 2/2] =?UTF-8?q?docs(client-sdk):=20the=20error-handling=20?= =?UTF-8?q?example=20tells=20the=20truth=20=E2=80=94=20VALIDATION=5FFAILED?= =?UTF-8?q?,=20and=20category/retryable=20are=20optional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example printed 'VALIDATION_ERROR' where data.create's validation failure actually emits VALIDATION_FAILED (the registered extension code; the batch-1 rename re-spelled the example's stale value without fixing it), and showed category/retryable as populated when no server surface emits either field today — they are optional contract fields the SDK now reads from the declared spot (#4006), set only when the server sends them. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018u8vDQLWPJxBWQESznDpSj --- content/docs/api/client-sdk.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index 481214fcc2..a3c4aab1f0 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -511,10 +511,10 @@ All errors follow a standardized format: try { await client.data.create('todo_task', { subject: '' }); } catch (error) { - console.error(error.code); // 'VALIDATION_ERROR' - console.error(error.category); // 'validation' + console.error(error.code); // 'VALIDATION_FAILED' console.error(error.httpStatus); // 400 - console.error(error.retryable); // false + console.error(error.category); // optional — set only when the server sent it + console.error(error.retryable); // optional — set only when the server sent it console.error(error.details); // { ... } } ```