Skip to content

Commit 0c4f5b2

Browse files
claudeos-zhuang
authored andcommitted
fix(client): retire the pre-#3842 parking-spot read; category/retryable read the declared spot (#4006, #4007)
- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018u8vDQLWPJxBWQESznDpSj
1 parent 3fe0ff1 commit 0c4f5b2

8 files changed

Lines changed: 85 additions & 29 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/client': minor
3+
---
4+
5+
`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).

content/docs/api/client-sdk.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -526,8 +526,8 @@ which server surface answered: the REST server replies with a flat
526526
`{ success, error: { code, message, httpStatus, details } }` body, and the
527527
client normalizes both before throwing. (Dispatcher bodies from servers older
528528
than #3842 put the status in `error.code` and the semantic code in
529-
`error.details.code`; the client still reads those, so an SDK newer than the
530-
server it talks to behaves the same.)
529+
`error.details.code`; that fallback read was retired in #4007SDK and server
530+
ship as one release group, so that pairing is not a supported deployment.)
531531

532532
### Per-field validation errors
533533

content/docs/api/error-catalog.mdx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -393,8 +393,10 @@ A rejection and a crash are told apart by the thrown error: a plain
393393
`throw new Error(msg)`, a sandboxed body's deliberate throw, or a
394394
`ValidationError` is a rejection (400); a `TypeError` / `ReferenceError` / a
395395
driver's own error class is a crash (500). An error carrying its own `status`
396-
is served with it. A validation rejection carries `details.code:
397-
'VALIDATION_FAILED'` and `details.fields[]`, identical to `/data`.
396+
is served with it. A validation rejection carries `error.code:
397+
'VALIDATION_FAILED'` with the per-field list in `error.details.fields[]` — the
398+
same code and field entries `/data` reports (the envelope assembly promotes a
399+
thrown error's `code` into `error.code`; `details` is context only).
398400

399401
```typescript
400402
const res = await fetch(`/api/v1/actions/${object}/${action}`, { /**/ });

content/docs/api/wire-format.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -461,8 +461,8 @@ status.
461461
Before [#3842](https://github.com/objectstack-ai/objectstack/issues/3842) this
462462
envelope put the HTTP status in `error.code` and the real code in
463463
`error.details.code`, `error.details.type` or `error.type`. `ObjectStackClient`
464-
reads all of them, so an SDK newer than the server it talks to is unaffected;
465-
code reading these bodies directly should move to `error.code`.
464+
no longer reads those legacy spots (#4007 — servers and SDKs ship as one
465+
release group); any code reading them directly should move to `error.code`.
466466
</Callout>
467467

468468
---

content/docs/releases/v17.mdx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -553,14 +553,16 @@ somewhere else and did, in three different somewhere-elses: `error.details.code`
553553
`body.error.details?.code ?? body.error.type` read with `body.error.code`, and
554554
a `body.error.code` read (for the status) with `body.error.httpStatus`.
555555
- **SDK callers need no change.** `ObjectStackClient` already normalised this —
556-
`err.code` semantic, `err.httpStatus` numeric — and still reads the old shape,
557-
so a client newer than its server behaves identically.
556+
`err.code` semantic, `err.httpStatus` numeric. The old-shape fallback read was
557+
retired later in this same window (#4007): SDK and server ship on one release
558+
train, and the ADR-0112 rename changed the code values a dug-out code would
559+
need to match.
558560
- **No code was renamed.** `PERMISSION_DENIED`, `ROUTE_NOT_FOUND`,
559561
`PASSWORD_EXPIRED`, `PROJECT_MEMBERSHIP_REQUIRED`, `VALIDATION_FAILED` and
560562
`unauthenticated` all reach the wire spelled exactly as before; only their
561563
field changed. Reconciling the platform's two code vocabularies is #3841.
562564
A branch with no code of its own now derives a `StandardErrorCode` from the
563-
status (`403``permission_denied`), spelled in one map in the spec.
565+
status (`403``PERMISSION_DENIED`, post-rename spelling), spelled in one map in the spec.
564566
- **Spec:** `ApiErrorSchema` gains optional `httpStatus`; `StandardErrorCode`
565567
gains `method_not_allowed` and `precondition_required` (both additive).
566568
`DispatcherErrorCode` changes members from `'404' | '405' | '501' | '503'` to

docs/adr/0112-error-code-vocabulary-and-ledger.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ Nine rulings, D1–D9.
9090

9191
**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.
9292

93+
> **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.
94+
9395
**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.
9496

9597
**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.

packages/client/src/client.test.ts

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1481,9 +1481,9 @@ describe('HTTP error shaping — envelope normalisation', () => {
14811481
/**
14821482
* What the runtime dispatcher put on the wire between #3918 and #3842: the
14831483
* HTTP status in `code`, the real code parked in `details`. Kept as a
1484-
* fixture because the SDK must keep reading it — a client build talks to
1485-
* whatever server version it is pointed at, so this is a live legacy-server
1486-
* shape, not a stale test.
1484+
* fixture to PIN the #4007 retirement — the SDK deliberately no longer
1485+
* digs this shape's parking spot (one release train; ADR-0112 renamed the
1486+
* code values, so a dug-out code matches no current branch anyway).
14871487
*/
14881488
const WRAPPED_LEGACY = {
14891489
success: false,
@@ -1524,29 +1524,36 @@ describe('HTTP error shaping — envelope normalisation', () => {
15241524
expect(caught.httpStatus).toBe(400);
15251525
});
15261526

1527-
it('reads the pre-#3842 wrapped shape identically (older server, newer SDK)', async () => {
1528-
// #3842 cured this at the producer, but an SDK build talks to whatever
1529-
// server it is pointed at, so the `details.code` hop must keep working.
1527+
it('no longer digs the pre-#3842 parking spot (#4007: retired pairing)', async () => {
1528+
// #3842 cured this at the producer; #4007 retired the client-side dig.
1529+
// SDK and server ship as a changesets fixed group, and ADR-0112
1530+
// batches 1–2 renamed the code VALUES — a code dug out of an old
1531+
// server's `details.code` would not match any branch written against
1532+
// the current catalog, so the read protected nothing.
15301533
const { client } = createMockClient(WRAPPED_LEGACY, 400);
15311534
const caught: any = await client.data.delete('pm_base', 'rec_1').catch((e) => e);
15321535

1533-
expect(caught.code).toBe('VALIDATION_FAILED');
1536+
expect(caught.code).toBeUndefined();
1537+
// Everything else about the legacy shape still normalises: `fields`
1538+
// lives at `error.details.fields` in the CURRENT wrapped envelope too,
1539+
// and the message/status reads are shape-independent.
15341540
expect(caught.fields).toEqual(FIELDS);
15351541
expect(caught.httpStatus).toBe(400);
1542+
expect(caught.message).toBe('Validation failed');
15361543
});
15371544

1538-
it('reports the same code from all three envelopes for the same failure', async () => {
1545+
it('reports the same code from both live envelopes for the same failure', async () => {
15391546
// The asymmetry #3636 → #3675 → #3689 → #3842 has been closing: which
15401547
// surface answered must stop being observable to the caller.
15411548
const codes = await Promise.all(
1542-
[FLAT, WRAPPED, WRAPPED_LEGACY].map((body) =>
1549+
[FLAT, WRAPPED].map((body) =>
15431550
createMockClient(body, 400).client.data
15441551
.delete('pm_base', 'rec_1')
15451552
.catch((e: any) => e.code),
15461553
),
15471554
);
15481555

1549-
expect(codes).toEqual(['VALIDATION_FAILED', 'VALIDATION_FAILED', 'VALIDATION_FAILED']);
1556+
expect(codes).toEqual(['VALIDATION_FAILED', 'VALIDATION_FAILED']);
15501557
});
15511558

15521559
it('exposes `fields[]` at the same place for BOTH envelopes', async () => {
@@ -1569,6 +1576,37 @@ describe('HTTP error shaping — envelope normalisation', () => {
15691576
expect(caught.code).toBe('PERMISSION_DENIED');
15701577
});
15711578

1579+
it('reads `category` / `retryable` from inside `error`, where the contract declares them (#4006)', async () => {
1580+
// These two used to be read from the body TOP level, where no envelope
1581+
// ever put them — `err.category` / `err.retryable` were `undefined`
1582+
// against every conformant server (ADR-0112 D9b).
1583+
const { client } = createMockClient(
1584+
{
1585+
success: false,
1586+
error: {
1587+
code: 'SERVICE_UNAVAILABLE',
1588+
message: 'engine still booting',
1589+
httpStatus: 503,
1590+
category: 'availability',
1591+
retryable: true,
1592+
},
1593+
},
1594+
503,
1595+
);
1596+
const caught: any = await client.data.delete('pm_base', 'rec_1').catch((e) => e);
1597+
1598+
expect(caught.category).toBe('availability');
1599+
expect(caught.retryable).toBe(true);
1600+
});
1601+
1602+
it('leaves `category` / `retryable` unset when the server sent neither', async () => {
1603+
const { client } = createMockClient(FLAT, 400);
1604+
const caught: any = await client.data.delete('pm_base', 'rec_1').catch((e) => e);
1605+
1606+
expect(caught.category).toBeUndefined();
1607+
expect(caught.retryable).toBeUndefined();
1608+
});
1609+
15721610
it('never reports a numeric code, even with no details to fall back on', async () => {
15731611
// A pre-#3918 dispatcher body: wrapped, but no `details` at all.
15741612
const { client } = createMockClient(

packages/client/src/index.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4455,20 +4455,23 @@ export class ObjectStackClient {
44554455
// semantic string on both surfaces now, and the number has its own
44564456
// `error.httpStatus`.
44574457
//
4458-
// The `details.code` hop stays in the chain regardless, and is NOT debt:
4459-
// an SDK build talks to whatever server version it is pointed at, so a
4460-
// client newer than its server must still find the code where that
4461-
// server put it. Same reasoning as the console's two-dialect read in
4462-
// objectui#2869. It is now a legacy-server fallback rather than the
4463-
// primary path, so the order below is unchanged but its meaning is.
4458+
// Between #3842 and #4007 a third read sat in this chain, digging the
4459+
// pre-#3842 parking spot (`error.details.code`) for "newer SDK, older
4460+
// server" pairings. #4007 retired it: SDK and server ship on one
4461+
// release train (a changesets fixed group), so that pairing is not a
4462+
// supported deployment — and ADR-0112 batches 1–2 renamed the code
4463+
// VALUES anyway, so a code dug out of an old server's parking spot
4464+
// would no longer match any branch written against the current
4465+
// catalog. Location-compat without value-compat protects nothing.
44644466
//
44654467
// So: `err.code` is always the semantic STRING (the numeric status is on
44664468
// `err.httpStatus`, where it always was), and `err.fields` is always the
4467-
// per-field list when the server sent one.
4469+
// per-field list when the server sent one. The two reads below are the
4470+
// two LIVE envelopes' declared spots, not a fallback chain — the flat
4471+
// shape's retirement belongs to the envelope-convergence line (#3843).
44684472
const asSemanticCode = (v: unknown) => (typeof v === 'string' && v ? v : undefined);
44694473
const errorCode =
44704474
asSemanticCode(errorBody?.code)
4471-
?? asSemanticCode(errorBody?.error?.details?.code)
44724475
?? asSemanticCode(errorBody?.error?.code);
44734476
const fieldErrors =
44744477
Array.isArray(errorBody?.fields) ? errorBody.fields
@@ -4483,9 +4486,13 @@ export class ObjectStackClient {
44834486

44844487
// Attach error details for programmatic access
44854488
error.code = errorCode;
4486-
error.category = errorBody?.category;
4489+
// `category` / `retryable` are declared INSIDE `error` (ApiErrorSchema);
4490+
// the flat envelope never carries them and nothing ever emitted them at
4491+
// the body top level, so the old top-level read returned `undefined`
4492+
// against every conformant server (ADR-0112 D9b, #4006).
4493+
error.category = errorBody?.error?.category;
44874494
error.httpStatus = res.status;
4488-
error.retryable = errorBody?.retryable;
4495+
error.retryable = errorBody?.error?.retryable;
44894496
// Prefer the wrapped envelope's own `details` over the whole body. The
44904497
// flat envelope has no top-level `details`, so it keeps falling through
44914498
// to `errorBody` exactly as before — only the wrapped shape changes,

0 commit comments

Comments
 (0)