Skip to content

Commit 5dc4d02

Browse files
os-zhuangclaude
andauthored
feat(spec)!: EnhancedApiError.fieldErrors → fields, tombstoned (ADR-0114 D4, #3977) (#4055)
* feat(spec)!: EnhancedApiError.fieldErrors -> fields, tombstoned (ADR-0114 D4, #3977) Completes the one thing #4035 decided but did not execute. The wire has always carried `fields`; `fieldErrors` was declared and emitted by nobody, so a reader keying on it was reading a field no server sent — ADR-0078's silently-inert declaration, on the error envelope. Tombstoned rather than deleted, because the schema is not .strict(): a plain removal would let a producer still writing the old name parse clean and lose the per-field detail, answering a validation failure that mentions no field. retiredKey() turns that into a rejection carrying the rename. Registered as a SEMANTIC chain entry, not a conversion, and the distinction is the point. A conversion rewrites author metadata; this is a response envelope, and no stack, example or template carries the key (checked across packages/, examples/, templates/, apps/). A no-op conversion with an identity fixture would claim a rewrite that does not exist. The analytics-query-request-* entries set the precedent one step earlier in the same major: an HTTP-only surface with nothing stored to rewrite is a semantic entry with a reason and an acceptance criterion — which is what actually reaches spec-changes.json, the upgrade guide and the spec_changes MCP tool. Docs followed, and two examples were teaching the opposite of this ADR: the server guide hand-rolled a Zod mapping with code: 'invalid_field' — a top-level code in a field position — and the client guide's error class carried the dead name. The server example now calls zodIssuesToFields, which is the mapping the platform's own routes use. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MaSQn77TT5fUgHK9CesaDK * docs(releases): v17 breaking-changes gets the error-code vocabulary entries it was missing The v17 page documents each breaking change under its own heading, and the whole ADR-0112 line had none — the largest wire-visible change in it appeared only as a passing mention 600 lines down. That is the worst gap to leave, because a missed error-code branch fails SILENTLY: nothing throws, the affordance it guarded just disappears. Eleven console branches broke that way without one test failing. Two entries: ADR-0112's vocabulary unification (with the generic-condition collapse table, the four routes that stopped putting a code in the message slot, and the case-insensitive advice for consumers spanning the upgrade), and ADR-0114's field-level catalog plus this PR's fieldErrors -> fields rename. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MaSQn77TT5fUgHK9CesaDK --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 01e124d commit 5dc4d02

13 files changed

Lines changed: 229 additions & 66 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
feat(spec)!: `EnhancedApiError.fieldErrors``fields`, tombstoned (ADR-0114 D4, #3977)
6+
7+
Completes ADR-0114 D4, which the field-level catalog PR (#4035) decided but left
8+
unexecuted because retiring an authorable key needs its own sequence.
9+
10+
**FROM → TO:** `EnhancedApiError.fieldErrors``EnhancedApiError.fields`. The array
11+
and its element shape are unchanged — only the property name.
12+
13+
The wire has always carried `fields`: the validators, import coercion,
14+
`validation-failure.ts`, `@objectstack/client` and the console's field-error
15+
extractor all say `fields`. `fieldErrors` was declared and emitted by nobody, so
16+
anyone reading `error.fieldErrors` was reading a field no server sent — ADR-0078's
17+
silently-inert declaration, sitting on the error envelope.
18+
19+
**The old key is tombstoned, not deleted.** `EnhancedApiErrorSchema` is not
20+
`.strict()`, so a plain removal would let a producer still writing `fieldErrors`
21+
parse clean and lose the per-field detail — a validation failure that mentions no
22+
field. Writing it now fails with the rename prescription instead
23+
(`retiredKey()`, ADR-0104).
24+
25+
**Migration:** read `error.fields`. There is nothing to run: this is a response
26+
envelope, so no stack, example or template carries the key and `os migrate meta`
27+
has no source to rewrite. The change is recorded as a semantic chain entry
28+
(`enhanced-api-error-field-errors-renamed`) with its reason and acceptance
29+
criterion, which is what reaches the generated upgrade guide and the
30+
`spec_changes` MCP tool.

content/docs/api/error-catalog.mdx

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ ObjectStack uses a structured error system with **9 error categories** and **53
5454

5555
### `VALIDATION_ERROR`
5656
**Cause:** Generic validation failure — request body does not match expected schema.
57-
**Fix:** Check the `fieldErrors` array for specific field-level issues.
57+
**Fix:** Check the `fields` array for specific field-level issues.
5858
**Retry:** `no_retry`
5959

6060
```json
@@ -64,7 +64,7 @@ ObjectStack uses a structured error system with **9 error categories** and **53
6464
"category": "validation",
6565
"httpStatus": 400,
6666
"retryable": false,
67-
"fieldErrors": [
67+
"fields": [
6868
{ "field": "email", "message": "Invalid email format", "code": "invalid_format" }
6969
]
7070
}
@@ -77,7 +77,7 @@ ObjectStack uses a structured error system with **9 error categories** and **53
7777

7878
### `MISSING_REQUIRED_FIELD`
7979
**Cause:** A required field was not provided in the request body.
80-
**Fix:** Include the missing field. Check `fieldErrors` for the field name.
80+
**Fix:** Include the missing field. Check `fields` for the field name.
8181
**Retry:** `no_retry`
8282

8383
### `INVALID_FORMAT`
@@ -429,7 +429,7 @@ interface EnhancedApiError {
429429
retryStrategy?: RetryStrategy; // Recommended retry approach
430430
retryAfter?: number; // Seconds to wait (for rate limits)
431431
details?: unknown; // Additional error context
432-
fieldErrors?: FieldError[]; // Field-specific validation errors
432+
fields?: FieldError[]; // One entry per offending value
433433
timestamp?: string; // ISO 8601 timestamp
434434
requestId?: string; // Request tracking ID
435435
traceId?: string; // Distributed trace ID
@@ -478,11 +478,12 @@ Routes that parse with Zod map its issue codes into this catalog rather than
478478
passing them through, so `fields[]` speaks one vocabulary whichever route served
479479
it.
480480

481-
<Callout type="warn">
482-
The declared envelope still calls this array `fieldErrors`, while every producer
483-
emits **`fields`**. Read `fields`. The rename is decided but deferred — retiring
484-
an authorable key needs a tombstone plus a migration, so it lands on its own
485-
(ADR-0114 D4).
481+
<Callout type="info">
482+
The array is `fields` everywhere — producers, the SDK, the console, and now the
483+
declared envelope. It was declared as `fieldErrors` and emitted by nobody; that
484+
name is **tombstoned** (ADR-0114 D4), so writing it fails to parse with the rename
485+
rather than silently losing the array. If you read `error.fieldErrors`, you were
486+
reading a field no server sent — move to `error.fields`.
486487
</Callout>
487488

488489
---
@@ -498,8 +499,8 @@ directly; if your server populates those on the response body, read them from
498499

499500
Two naming details matter here:
500501

501-
- The per-field list is `apiError.fields`, matching the wire (`fields[]`), not
502-
the spec contract's `fieldErrors`. Each entry is `{ field, code, message }`.
502+
- The per-field list is `apiError.fields`, matching the wire and (since ADR-0114
503+
D4) the spec contract too. Each entry is `{ field, code, message }`.
503504
It is left **unset** when the server reported no per-field detail, so
504505
`if (apiError.fields)` tests "this failure is field-anchored".
505506
- `apiError.code` is always the semantic code as a **string**. The numeric HTTP
@@ -514,10 +515,9 @@ Two naming details matter here:
514515
```typescript
515516
import type { EnhancedApiError, FieldError } from '@objectstack/spec/api';
516517

517-
// The spec type names the per-field list `fieldErrors`; what the client
518-
// actually attaches at runtime is `fields` (the wire name). Widen the cast
519-
// until the two names converge.
520-
type ThrownApiError = EnhancedApiError & { fields?: FieldError[] };
518+
// The two names converged in ADR-0114 D4: the spec type and the client both say
519+
// `fields`, so no widening cast is needed any more.
520+
type ThrownApiError = EnhancedApiError;
521521

522522
async function handleApiCall() {
523523
try {
@@ -528,9 +528,7 @@ async function handleApiCall() {
528528

529529
switch (apiError.category) {
530530
case 'validation':
531-
// Show field-level errors to the user. `fields` is what the client
532-
// attaches (the wire name); the spec type calls the same idea
533-
// `fieldErrors`.
531+
// Show field-level errors to the user. One entry per offending value.
534532
apiError.fields?.forEach(fe => {
535533
showFieldError(fe.field, fe.message);
536534
});

content/docs/api/error-handling-client.mdx

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,9 @@ interface ErrorResponse {
3131
retryable?: boolean; // Whether the request can be retried
3232
retryStrategy?: string;
3333
retryAfter?: number; // Seconds to wait before retrying (rate limits)
34-
// ⚠️ Declared as `fieldErrors`; every producer emits `fields`. Read `fields`.
35-
// (ADR-0114 D4 — the rename needs a tombstone + migration, so it lands on
36-
// its own.)
37-
fieldErrors?: Array<{
34+
// One entry per offending value. Named `fields` on the wire AND in the spec
35+
// contract since ADR-0114 D4; the old `fieldErrors` is tombstoned.
36+
fields?: Array<{
3837
field: string; // Field path (supports dot notation)
3938
// Which CONSTRAINT the value violated — a lowercase `FieldErrorCode`
4039
// (`required`, `max_length`, `invalid_email`, …), NOT a top-level code.
@@ -73,7 +72,7 @@ class ObjectStackError extends Error {
7372
code: string;
7473
status: number;
7574
retryAfter?: number;
76-
fieldErrors: ErrorResponse['error']['fieldErrors'];
75+
fields: ErrorResponse['error']['fields'];
7776
details: ErrorResponse['error']['details'];
7877
requestId?: string;
7978

@@ -83,7 +82,7 @@ class ObjectStackError extends Error {
8382
this.code = response.error.code;
8483
this.status = response.error.httpStatus ?? 0;
8584
this.retryAfter = response.error.retryAfter;
86-
this.fieldErrors = response.error.fieldErrors;
85+
this.fields = response.error.fields;
8786
this.details = response.error.details;
8887
this.requestId = response.error.requestId ?? response.meta?.requestId;
8988
}
@@ -111,7 +110,7 @@ class ObjectStackError extends Error {
111110
/** Extract field-level errors as a map of field path → message */
112111
get fieldErrorMap(): Record<string, string> {
113112
const errors: Record<string, string> = {};
114-
for (const detail of this.fieldErrors ?? []) {
113+
for (const detail of this.fields ?? []) {
115114
if (detail.field) {
116115
errors[detail.field] = detail.message;
117116
}

content/docs/api/error-handling-server.mdx

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ description: Best practices for handling and throwing errors in ObjectStack plug
88
This guide covers best practices for handling and throwing errors within ObjectStack plugins and server-side code — from creating custom errors with proper codes to transaction rollback and integration with `safeParsePretty()`.
99

1010
<Callout type="info">
11-
**Error Contract:** ObjectStack defines a standardized error envelope, `ErrorResponseSchema` (exported from `@objectstack/spec/api`). It wraps an `EnhancedApiError` with a machine-readable `code`, `httpStatus`, optional `category`, and `fieldErrors`. This is the spec's target contract for plugin/hook authors to throw against — but the kernel REST server (`@objectstack/rest`) that serves `/api/v1/data/*` does not auto-translate an arbitrary thrown error into this envelope today. It emits a flatter `{ error, code }` shape and only recognizes a small, fixed set of SCREAMING_SNAKE_CASE codes (`VALIDATION_FAILED`, `PERMISSION_DENIED`, `RECORD_NOT_FOUND`, `CONCURRENT_UPDATE`, `DELETE_RESTRICTED`, …). See the [API Overview](/docs/api#error-handling) and [Wire Format](/docs/api/wire-format#7-error-response-format) for both wire formats in use, and the [Error Catalog](/docs/api/error-catalog) for the full `StandardErrorCode` list.
11+
**Error Contract:** ObjectStack defines a standardized error envelope, `ErrorResponseSchema` (exported from `@objectstack/spec/api`). It wraps an `EnhancedApiError` with a machine-readable `code`, `httpStatus`, optional `category`, and `fields` (one entry per offending value). This is the spec's target contract for plugin/hook authors to throw against — but the kernel REST server (`@objectstack/rest`) that serves `/api/v1/data/*` does not auto-translate an arbitrary thrown error into this envelope today. It emits a flatter `{ error, code }` shape and only recognizes a small, fixed set of SCREAMING_SNAKE_CASE codes (`VALIDATION_FAILED`, `PERMISSION_DENIED`, `RECORD_NOT_FOUND`, `CONCURRENT_UPDATE`, `DELETE_RESTRICTED`, …). See the [API Overview](/docs/api#error-handling) and [Wire Format](/docs/api/wire-format#7-error-response-format) for both wire formats in use, and the [Error Catalog](/docs/api/error-catalog) for the full `StandardErrorCode` list.
1212
</Callout>
1313

1414
---
@@ -18,7 +18,7 @@ This guide covers best practices for handling and throwing errors within ObjectS
1818
ObjectStack does not ship a base error class — there is no `ObjectStackError`
1919
export. Instead, define a small server-side helper that extends the native
2020
`Error` and carries the fields from the `EnhancedApiError` schema (`code`,
21-
`httpStatus`, `fieldErrors`; `category` is optional). The valid `code` values are
21+
`httpStatus`, `fields`; `category` is optional). The valid `code` values are
2222
SCREAMING_SNAKE (ADR-0112): a `StandardErrorCode` member (e.g. `VALIDATION_ERROR`,
2323
`RESOURCE_CONFLICT`, `PERMISSION_DENIED`) or a code your package registered in
2424
`ERROR_CODE_LEDGER` (see `@objectstack/spec/api`).
@@ -27,15 +27,15 @@ SCREAMING_SNAKE (ADR-0112): a `StandardErrorCode` member (e.g. `VALIDATION_ERROR
2727
```typescript
2828
import type { ErrorResponse, StandardErrorCode } from '@objectstack/spec/api';
2929

30-
type FieldError = NonNullable<ErrorResponse['error']['fieldErrors']>[number];
30+
type FieldError = NonNullable<ErrorResponse['error']['fields']>[number];
3131

3232
// Domain helper: a throwable error that maps cleanly onto EnhancedApiError.
3333
class AppError extends Error {
3434
constructor(
3535
public code: StandardErrorCode,
3636
message: string,
3737
public httpStatus: number,
38-
public fieldErrors?: FieldError[],
38+
public fields?: FieldError[],
3939
) {
4040
super(message);
4141
this.name = 'AppError';
@@ -165,20 +165,25 @@ Format validation errors so the client can map them to specific form fields:
165165
```typescript
166166
import { z } from 'zod';
167167
import { Hook } from '@objectstack/spec/data';
168+
import { zodIssuesToFields } from '@objectstack/rest';
168169

169170
// Map a ZodError onto an AppError carrying field-level errors.
170-
function createValidationError(zodError: z.ZodError): AppError {
171-
const fieldErrors = zodError.issues.map((issue) => ({
172-
field: issue.path.join('.'),
173-
message: issue.message,
174-
code: 'invalid_field' as const,
175-
}));
171+
//
172+
// Do NOT hand-roll this mapping, and do not reach for a top-level code here: a
173+
// per-field `code` is a `FieldErrorCode` naming the CONSTRAINT the value violated
174+
// (ADR-0114), and Zod's own issue codes are Zod's API rather than the wire's.
175+
// `zodIssuesToFields` does the translation the platform's own routes use —
176+
// `too_small` becomes `min_length` / `min_value` / `min_items` depending on what
177+
// was too small, and a MISSING property becomes `required` instead of the
178+
// `invalid_type` Zod reports for it.
179+
function createValidationError(zodError: z.ZodError, input?: unknown): AppError {
180+
const fields = zodIssuesToFields(zodError.issues, input);
176181

177182
return new AppError(
178183
'VALIDATION_ERROR',
179-
`Validation failed: ${fieldErrors.length} error(s)`,
184+
`Validation failed: ${fields.length} error(s)`,
180185
400,
181-
fieldErrors,
186+
fields,
182187
);
183188
}
184189

content/docs/references/api/errors.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ const result = EnhancedApiError.parse(data);
5353
| **retryStrategy** | `Enum<'no_retry' \| 'retry_immediate' \| 'retry_backoff' \| 'retry_after'>` | optional | Recommended retry strategy |
5454
| **retryAfter** | `number` | optional | Seconds to wait before retrying |
5555
| **details** | `any` | optional | Additional error context |
56-
| **fieldErrors** | `{ field: string; code: Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| 'invalid_boolean' \| 'invalid_number' \| 'invalid_date' \| 'invalid_time' \| 'invalid_email' \| 'invalid_url' \| 'invalid_phone' \| 'invalid_json' \| 'invalid_format' \| 'min_length' \| 'max_length' \| 'min_value' \| 'max_value' \| 'min_items' \| 'max_items' \| 'invalid_option' \| 'invalid_value' \| 'reference_not_found' \| 'reference_ambiguous' \| 'rule_violation' \| 'json_schema_violation' \| 'invalid_initial_state' \| 'invalid_transition'>; message: string; value?: any; … }[]` | optional | Field-specific validation errors (wire name is `fields` — see ADR-0114 D4) |
56+
| **fields** | `{ field: string; code: Enum<'required' \| 'invalid_type' \| 'invalid_shape' \| 'unknown_field' \| 'invalid_boolean' \| 'invalid_number' \| 'invalid_date' \| 'invalid_time' \| 'invalid_email' \| 'invalid_url' \| 'invalid_phone' \| 'invalid_json' \| 'invalid_format' \| 'min_length' \| 'max_length' \| 'min_value' \| 'max_value' \| 'min_items' \| 'max_items' \| 'invalid_option' \| 'invalid_value' \| 'reference_not_found' \| 'reference_ambiguous' \| 'rule_violation' \| 'json_schema_violation' \| 'invalid_initial_state' \| 'invalid_transition'>; message: string; value?: any; … }[]` | optional | One entry per offending value |
57+
| **fieldErrors** | `any` | optional | [REMOVED] `EnhancedApiError.fieldErrors` was renamed to `fields` in @objectstack/spec 17 (ADR-0114 D4, #3977) — the array is unchanged, only the property name. Every producer already emitted `fields`; `fieldErrors` was declared and never emitted, so a reader keying on it was reading a field no server sent. |
5758
| **timestamp** | `string` | optional | When the error occurred |
5859
| **requestId** | `string` | optional | Request ID for tracking |
5960
| **traceId** | `string` | optional | Distributed trace ID |

content/docs/releases/v17.mdx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,65 @@ declares `ai.exposed: true` + `ai.description` **and** has a headless path —
324324
warns on a stack declaring `stack.agents`, which the runtime has filtered from
325325
the catalog since ADR-0063.
326326

327+
### Error codes are one SCREAMING_SNAKE vocabulary, and `error.code` is a closed set (ADR-0112, #3841)
328+
329+
The platform shipped two live error-code dialects: `StandardErrorCode` declared
330+
lowercase `snake_case` members while 139 codes on the wire were SCREAMING_SNAKE,
331+
and `ApiErrorSchema.code` was a bare `z.string()`, so nothing validated either.
332+
Now there is one vocabulary — the standard catalog plus `ERROR_CODE_LEDGER` for
333+
service-specific codes — and `error.code` validates against their union, so an
334+
unregistered code fails parse instead of quietly becoming a new dialect.
335+
336+
**Wire-visible.** Codes change spelling on every surface that spoke lowercase.
337+
Generic conditions collapse onto the standard catalog rather than keeping a
338+
synonym: `unauthorized`/`unauthenticated``UNAUTHENTICATED`, `forbidden`
339+
`PERMISSION_DENIED`, `not_found``RESOURCE_NOT_FOUND`, `internal`
340+
`INTERNAL_ERROR`, `unavailable``SERVICE_UNAVAILABLE`, `not_supported`
341+
`NOT_IMPLEMENTED`, `bad_request``INVALID_REQUEST` (a status name is not a
342+
semantic code). Domain conditions get a registered SCREAMING code.
343+
344+
**Migration:** branch on `error.code` **values**, and do not pattern-match their
345+
case. A code branch that misses fails *silently* — nothing throws, the affordance
346+
it guards just disappears — which is how eleven console branches broke without a
347+
single test failing (objectui#2977). If you support servers on both sides of the
348+
upgrade, compare case-insensitively; that is what the console does.
349+
350+
Four routes also stop putting a code in the message slot: the webhook redeliver
351+
route, the API-trigger webhook and two `rest` routes answered
352+
`{ success: false, error: '<code>', message }`. They now emit
353+
`error: { code, message }`, so a client reading `body.error` as a string on those
354+
routes must read `body.error.code`.
355+
356+
Not swept, deliberately: `sys_metadata_audit.code` (persisted audit history, and
357+
the column also holds non-errors like `ok`), diagnostics records that ship inside
358+
a 200, field-level codes (see below), and the CLI's `--json` output contract.
359+
360+
### Field-level error codes are their own closed catalog, and `fieldErrors` becomes `fields` (ADR-0114, #3977)
361+
362+
The per-field `code` inside `fields[]` is a **different** vocabulary from
363+
`error.code`, and it stays lowercase on purpose: a top-level code names the
364+
condition the *request* hit, while a field-level code names the *constraint* the
365+
value violated — and constraints are declared in the metadata's own snake_case,
366+
so `max_length` the code and `max_length: 50` the property are the same word.
367+
`FieldErrorCode` closes that set (27 members) and `FieldErrorSchema.code`
368+
validates against it, where it used to be `z.string()`.
369+
370+
**`EnhancedApiError.fieldErrors` is renamed to `fields`** — the name every
371+
producer already emitted. The old key was declared and emitted by nobody, so a
372+
reader keying on it was reading a field no server sent. It is tombstoned rather
373+
than deleted: writing it now fails with the rename instead of parsing clean and
374+
losing the array.
375+
376+
**Wire-visible.** Routes that validate with Zod stopped leaking Zod's issue codes:
377+
`too_small` now becomes `min_length` / `min_value` / `min_items` depending on what
378+
was too small, `unrecognized_keys` becomes `unknown_field`, and — the one that was
379+
a real bug — a **missing** required property now reports `required` instead of the
380+
`invalid_type` Zod uses for it, so a form marks it as missing rather than
381+
wrong-typed.
382+
383+
**Migration:** read `error.fields`; branch on the catalog's lowercase codes for
384+
per-field handling, and show `message` to the user rather than the code.
385+
327386
### Approval requests are visible to participants, not the whole tenant (#3590)
328387

329388
`getRequest` / `listRequests` / `countRequests` query with `SYSTEM_CTX` to

0 commit comments

Comments
 (0)