Skip to content

Commit 10c9656

Browse files
committed
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
1 parent ec36ba8 commit 10c9656

12 files changed

Lines changed: 170 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 |

0 commit comments

Comments
 (0)