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
5 changes: 5 additions & 0 deletions .changeset/client-retires-parking-spot-read.md
Original file line number Diff line number Diff line change
@@ -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).
10 changes: 5 additions & 5 deletions content/docs/api/client-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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); // { ... }
}
```
Expand All @@ -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

Expand Down
6 changes: 4 additions & 2 deletions content/docs/api/error-catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`, { /* … */ });
Expand Down
4 changes: 2 additions & 2 deletions content/docs/api/wire-format.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
</Callout>

---
Expand Down
8 changes: 5 additions & 3 deletions content/docs/releases/v17.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/adr/0112-error-code-vocabulary-and-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
58 changes: 48 additions & 10 deletions packages/client/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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(
Expand Down
27 changes: 17 additions & 10 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
Loading