Skip to content

Commit 7d7521f

Browse files
os-zhuangclaude
andauthored
feat(spec,rest,objectql)!: a closed field-level error catalog, and Zod stops leaking onto the wire (#3977) (#4035)
* feat(spec,rest,objectql)!: a closed field-level error catalog, and Zod stops leaking onto the wire (#3977) Settles what ADR-0112 D6 deferred, recorded as ADR-0114. FieldErrorCode: 27 members, closed, and lowercase on purpose. A top-level code names the condition the REQUEST hit; a field-level one names the CONSTRAINT the value violated, and constraints are declared in the metadata's own snake_case, so 'max_length' the code and 'max_length: 50' the property are the same word. Survey first, per the issue: nothing branches on these values — objectui's only consumer uses code as a display fallback, and every framework match is a test — so the casing was chosen on principle rather than migration cost. FieldErrorSchema.code tightens from z.string() to the enum; objectql's FieldValidationError (a hand-listed union — the shape that bit batch 2) and rest's FieldCoerceError (a bare string) now reference the catalog so the three cannot drift. Zod stops reaching the wire. zodIssuesToFields maps via origin/format, which also resolves the too_small ambiguity #3977 thought had no clean answer. A missing property now reports 'required' instead of 'invalid_type' — Zod spells absent as a type mismatch against undefined, and passing that through made a form mark a MISSING input as the wrong TYPE. The two are indistinguishable on the issue alone (v4 carries no input value), so the mapper takes the parsed input and walks the path; without it, 'invalid_type' stands rather than a guess read out of Zod's message text. Two things the repo's own gates caught, both kept as evidence in comments: a real route's test had pinned the invalid_type passthrough, and two doc examples put top-level concepts (lock_conflict, invalid_field) in a field-code position — one of which the catalog answers exactly (invalid_transition). Not done, deliberately: EnhancedApiErrorSchema.fieldErrors keeps a name no producer emits. Retiring an authorable key needs ADR-0104's tombstone plus migration, so it gets its own change; the property carries a banner meanwhile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MaSQn77TT5fUgHK9CesaDK * docs(api): the client error-shape reference stops teaching a top-level code as a field code Found by acting on the docs-drift advisory rather than skimming it: the client-facing shape declared `code: string // e.g. "INVALID_FORMAT"` on the per-field entry — a SCREAMING top-level member in the one position that is deliberately a different, lowercase vocabulary. An AI copying this example would emit a code the schema now rejects. Also flags that the declared array name (`fieldErrors`) is not the one producers emit (`fields`), which is the mismatch ADR-0114 D4 records rather than fixes. 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 99b4392 commit 7d7521f

18 files changed

Lines changed: 664 additions & 65 deletions
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/rest": minor
4+
"@objectstack/objectql": minor
5+
---
6+
7+
feat(spec,rest,objectql)!: a closed field-level error catalog, and Zod stops leaking onto the wire (#3977)
8+
9+
Settles the vocabulary ADR-0112 D6 deferred, per [ADR-0114](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0114-field-level-error-code-catalog.md).
10+
11+
**`FieldErrorCode` — a closed, lowercase catalog.** 27 members covering what the
12+
six emitters already emit. `FieldErrorSchema.code` tightens from `z.string()` to
13+
this enum, so a validation body's per-field codes are validated for the first time.
14+
`FieldValidationError.code` (objectql) and `FieldCoerceError.code` (rest) stop
15+
being a hand-listed union and a bare `string` respectively and reference the
16+
catalog, so the three cannot drift apart.
17+
18+
Lowercase is deliberate, not an oversight against ADR-0112's SCREAMING_SNAKE: a
19+
top-level code names the condition the *request* hit, while a field-level code
20+
names the *constraint* the value violated — and constraints are declared in the
21+
metadata's own snake_case, so `max_length` the code and `max_length: 50` the
22+
property are the same word on purpose.
23+
24+
**Zod issue codes no longer reach the wire (wire-visible).** Routes that validate
25+
with Zod passed its vocabulary straight through, so `fields[]` spoke a different
26+
language depending on which route served it, and `too_small` was ambiguous between
27+
a short string, a small number and a short array. `zodIssuesToFields` now maps
28+
using Zod's `origin`/`format`:
29+
30+
| Was | Now |
31+
|:---|:---|
32+
| `too_small` | `min_length` / `min_value` / `min_items` |
33+
| `too_big` | `max_length` / `max_value` / `max_items` |
34+
| `invalid_format` | `invalid_email` / `invalid_url` / `invalid_format` |
35+
| `invalid_value` | `invalid_option` |
36+
| `unrecognized_keys` | `unknown_field` |
37+
| `invalid_union`, `invalid_element`, `invalid_key` | `invalid_shape` |
38+
39+
**A missing required property now reports `required`, not `invalid_type`.** Zod
40+
spells "absent" as a type mismatch against `undefined`, so passing it through made
41+
a form mark a *missing* input as the wrong *type*. The two are indistinguishable on
42+
the issue alone, so the mapper takes the parsed input as an optional argument and
43+
walks the issue path; a caller that cannot supply it keeps `invalid_type` rather
44+
than guessing.
45+
46+
**`unknown_param``unknown_field`.** `ActionParamIssue.code` references the
47+
catalog instead of its own literal union; the `param` key beside it already says
48+
what was addressed.
49+
50+
**Not changed:** `EnhancedApiErrorSchema.fieldErrors` keeps its name even though
51+
every producer emits `fields`. Retiring an authorable key needs a tombstone plus a
52+
migration (ADR-0104's contract guard), so it lands on its own — the property now
53+
carries a banner saying which name the wire uses.

content/docs/api/error-catalog.mdx

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -443,15 +443,48 @@ interface EnhancedApiError {
443443
```typescript
444444
interface FieldError {
445445
field: string; // Field path (supports dot notation)
446-
code: string; // Field-level error code — a SEPARATE vocabulary from
447-
// error.code (`required`, `max_length`, `invalid_email`, …);
448-
// its own catalog is #3977 (ADR-0112 D6)
446+
code: FieldErrorCode; // Which CONSTRAINT the value violated — a closed,
447+
// lowercase catalog, separate from error.code (ADR-0114)
449448
message: string; // Human-readable error for this field
450449
value?: unknown; // The invalid value (if safe to include)
451450
constraint?: unknown; // The constraint that was violated (e.g., max length)
452451
}
453452
```
454453

454+
### Field-level codes
455+
456+
`FieldError.code` is its **own** closed vocabulary, and it is lowercase where
457+
`error.code` is SCREAMING. That is deliberate (ADR-0114): a top-level code names
458+
the condition the *request* hit, while a field-level code names the *constraint*
459+
the value violated — and constraints are declared in the metadata's own
460+
snake_case, so the code and the schema property are the same word.
461+
462+
```
463+
{ required: true } → code 'required'
464+
{ max_length: 50 } → code 'max_length'
465+
{ min_value: 0 } → code 'min_value'
466+
```
467+
468+
| Group | Codes |
469+
|:---|:---|
470+
| Presence and shape | `required`, `invalid_type`, `invalid_shape`, `unknown_field` |
471+
| Per-type parse | `invalid_boolean`, `invalid_number`, `invalid_date`, `invalid_time`, `invalid_email`, `invalid_url`, `invalid_phone`, `invalid_json`, `invalid_format` |
472+
| Bounded ranges | `min_length`, `max_length`, `min_value`, `max_value`, `min_items`, `max_items` |
473+
| Closed sets and references | `invalid_option`, `invalid_value`, `reference_not_found`, `reference_ambiguous` |
474+
| Declarative rules | `rule_violation`, `json_schema_violation`, `invalid_initial_state`, `invalid_transition` |
475+
476+
Branch on `code` to decide *how* to mark an input; show `message` to the user.
477+
Routes that parse with Zod map its issue codes into this catalog rather than
478+
passing them through, so `fields[]` speaks one vocabulary whichever route served
479+
it.
480+
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).
486+
</Callout>
487+
455488
---
456489

457490
## Client-Side Error Handling

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ Every error response from ObjectStack follows this shape:
1919

2020
{/* os:check */}
2121
```typescript
22+
import type { FieldErrorCode } from '@objectstack/spec/api';
23+
2224
interface ErrorResponse {
2325
success: false; // Always false for error responses
2426
error: {
@@ -29,9 +31,15 @@ interface ErrorResponse {
2931
retryable?: boolean; // Whether the request can be retried
3032
retryStrategy?: string;
3133
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.)
3237
fieldErrors?: Array<{
3338
field: string; // Field path (supports dot notation)
34-
code: string; // Per-field error code (e.g. "INVALID_FORMAT")
39+
// Which CONSTRAINT the value violated — a lowercase `FieldErrorCode`
40+
// (`required`, `max_length`, `invalid_email`, …), NOT a top-level code.
41+
// The two vocabularies are separate on purpose: see ADR-0114.
42+
code: FieldErrorCode;
3543
message: string; // Detail message
3644
value?: unknown; // The invalid value that was provided
3745
constraint?: unknown;

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@ class TaskAlreadyCompletedError extends AppError {
4848
'RESOURCE_CONFLICT',
4949
`Task ${taskId} is already completed and cannot be modified`,
5050
409,
51-
[{ field: 'status', code: 'lock_conflict', message: 'Task is in terminal state' }],
51+
// No per-field entry: a terminal-state conflict is a condition of the
52+
// RECORD, not a constraint some value violated, and the top-level
53+
// RESOURCE_CONFLICT already says so. `fields[]` is for "this value is
54+
// wrong", which is why its codes name constraints.
5255
);
5356
}
5457
}
@@ -59,14 +62,16 @@ class InvalidStatusTransitionError extends AppError {
5962
'INVALID_FIELD',
6063
`Cannot transition from '${from}' to '${to}'`,
6164
400,
62-
[{ field: 'status', code: 'invalid_field', message: `Invalid transition: ${from} → ${to}` }],
65+
// `invalid_transition` — the catalog has a member for exactly this, and it
66+
// is more useful to a form than a generic "this field is invalid".
67+
[{ field: 'status', code: 'invalid_transition', message: `Invalid transition: ${from} → ${to}` }],
6368
);
6469
}
6570
}
6671
```
6772

6873
<Callout type="tip">
69-
**Error Codes:** The top-level `code` is SCREAMING_SNAKE (`StandardErrorCode` or a ledger-registered code). Per-field `code` values are a **separate, lowercase vocabulary** — the validators emit `required`, `max_length`, `invalid_email`, … and its catalog is being settled in [#3977](https://github.com/objectstack-ai/objectstack/issues/3977) (ADR-0112 D6). Use the `category` and `httpStatus` that match — `ErrorHttpStatusMap` in `@objectstack/spec/api` maps each category to its HTTP status.
74+
**Error Codes:** The top-level `code` is SCREAMING_SNAKE (`StandardErrorCode` or a ledger-registered code). Per-field `code` values are a **separate, lowercase catalog**`FieldErrorCode`, closed, naming the constraint the value violated (`required`, `max_length`, `invalid_transition`, …). See the [Error Catalog](/docs/api/error-catalog#field-level-codes); the two vocabularies and why they differ are ADR-0114. Use the `category` and `httpStatus` that match — `ErrorHttpStatusMap` in `@objectstack/spec/api` maps each category to its HTTP status.
7075
</Callout>
7176

7277
---

content/docs/references/api/errors.mdx

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ Industry alignment: Google Cloud Errors, AWS Error Codes, Stripe API Errors
3030
## TypeScript Usage
3131

3232
```typescript
33-
import { EnhancedApiError, ErrorCategory, ErrorResponse, FieldError, RetryStrategy, StandardErrorCode } from '@objectstack/spec/api';
34-
import type { EnhancedApiError, ErrorCategory, ErrorResponse, FieldError, RetryStrategy, StandardErrorCode } from '@objectstack/spec/api';
33+
import { EnhancedApiError, ErrorCategory, ErrorResponse, FieldError, FieldErrorCode, RetryStrategy, StandardErrorCode } from '@objectstack/spec/api';
34+
import type { EnhancedApiError, ErrorCategory, ErrorResponse, FieldError, FieldErrorCode, RetryStrategy, StandardErrorCode } from '@objectstack/spec/api';
3535

3636
// Validate data
3737
const result = EnhancedApiError.parse(data);
@@ -53,7 +53,7 @@ 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: string; message: string; value?: any; … }[]` | optional | Field-specific validation errors |
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) |
5757
| **timestamp** | `string` | optional | When the error occurred |
5858
| **requestId** | `string` | optional | Request ID for tracking |
5959
| **traceId** | `string` | optional | Distributed trace ID |
@@ -100,12 +100,47 @@ const result = EnhancedApiError.parse(data);
100100
| Property | Type | Required | Description |
101101
| :--- | :--- | :--- | :--- |
102102
| **field** | `string` || Field path (supports dot notation) |
103-
| **code** | `string` || Error code for this field (field-level vocabulary — see #3977) |
103+
| **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'>` || Which constraint the value violated (field-level catalog, ADR-0114) |
104104
| **message** | `string` || Human-readable error message |
105105
| **value** | `any` | optional | The invalid value that was provided |
106106
| **constraint** | `any` | optional | The constraint that was violated (e.g., max length) |
107107

108108

109+
---
110+
111+
## FieldErrorCode
112+
113+
### Allowed Values
114+
115+
* `required`
116+
* `invalid_type`
117+
* `invalid_shape`
118+
* `unknown_field`
119+
* `invalid_boolean`
120+
* `invalid_number`
121+
* `invalid_date`
122+
* `invalid_time`
123+
* `invalid_email`
124+
* `invalid_url`
125+
* `invalid_phone`
126+
* `invalid_json`
127+
* `invalid_format`
128+
* `min_length`
129+
* `max_length`
130+
* `min_value`
131+
* `max_value`
132+
* `min_items`
133+
* `max_items`
134+
* `invalid_option`
135+
* `invalid_value`
136+
* `reference_not_found`
137+
* `reference_ambiguous`
138+
* `rule_violation`
139+
* `json_schema_violation`
140+
* `invalid_initial_state`
141+
* `invalid_transition`
142+
143+
109144
---
110145

111146
## RetryStrategy

0 commit comments

Comments
 (0)