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
30 changes: 30 additions & 0 deletions .changeset/adr-0114-field-errors-rename.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@objectstack/spec": major
---

feat(spec)!: `EnhancedApiError.fieldErrors` → `fields`, tombstoned (ADR-0114 D4, #3977)

Completes ADR-0114 D4, which the field-level catalog PR (#4035) decided but left
unexecuted because retiring an authorable key needs its own sequence.

**FROM → TO:** `EnhancedApiError.fieldErrors` → `EnhancedApiError.fields`. The array
and its element shape are unchanged — only the property name.

The wire has always carried `fields`: the validators, import coercion,
`validation-failure.ts`, `@objectstack/client` and the console's field-error
extractor all say `fields`. `fieldErrors` was declared and emitted by nobody, so
anyone reading `error.fieldErrors` was reading a field no server sent — ADR-0078's
silently-inert declaration, sitting on the error envelope.

**The old key is tombstoned, not deleted.** `EnhancedApiErrorSchema` is not
`.strict()`, so a plain removal would let a producer still writing `fieldErrors`
parse clean and lose the per-field detail — a validation failure that mentions no
field. Writing it now fails with the rename prescription instead
(`retiredKey()`, ADR-0104).

**Migration:** read `error.fields`. There is nothing to run: this is a response
envelope, so no stack, example or template carries the key and `os migrate meta`
has no source to rewrite. The change is recorded as a semantic chain entry
(`enhanced-api-error-field-errors-renamed`) with its reason and acceptance
criterion, which is what reaches the generated upgrade guide and the
`spec_changes` MCP tool.
34 changes: 16 additions & 18 deletions content/docs/api/error-catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ ObjectStack uses a structured error system with **9 error categories** and **53

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

```json
Expand All @@ -64,7 +64,7 @@ ObjectStack uses a structured error system with **9 error categories** and **53
"category": "validation",
"httpStatus": 400,
"retryable": false,
"fieldErrors": [
"fields": [
{ "field": "email", "message": "Invalid email format", "code": "invalid_format" }
]
}
Expand All @@ -77,7 +77,7 @@ ObjectStack uses a structured error system with **9 error categories** and **53

### `MISSING_REQUIRED_FIELD`
**Cause:** A required field was not provided in the request body.
**Fix:** Include the missing field. Check `fieldErrors` for the field name.
**Fix:** Include the missing field. Check `fields` for the field name.
**Retry:** `no_retry`

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

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

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

Two naming details matter here:

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

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

async function handleApiCall() {
try {
Expand All @@ -528,9 +528,7 @@ async function handleApiCall() {

switch (apiError.category) {
case 'validation':
// Show field-level errors to the user. `fields` is what the client
// attaches (the wire name); the spec type calls the same idea
// `fieldErrors`.
// Show field-level errors to the user. One entry per offending value.
apiError.fields?.forEach(fe => {
showFieldError(fe.field, fe.message);
});
Expand Down
13 changes: 6 additions & 7 deletions content/docs/api/error-handling-client.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,9 @@ interface ErrorResponse {
retryable?: boolean; // Whether the request can be retried
retryStrategy?: string;
retryAfter?: number; // Seconds to wait before retrying (rate limits)
// ⚠️ Declared as `fieldErrors`; every producer emits `fields`. Read `fields`.
// (ADR-0114 D4 — the rename needs a tombstone + migration, so it lands on
// its own.)
fieldErrors?: Array<{
// One entry per offending value. Named `fields` on the wire AND in the spec
// contract since ADR-0114 D4; the old `fieldErrors` is tombstoned.
fields?: Array<{
field: string; // Field path (supports dot notation)
// Which CONSTRAINT the value violated — a lowercase `FieldErrorCode`
// (`required`, `max_length`, `invalid_email`, …), NOT a top-level code.
Expand Down Expand Up @@ -73,7 +72,7 @@ class ObjectStackError extends Error {
code: string;
status: number;
retryAfter?: number;
fieldErrors: ErrorResponse['error']['fieldErrors'];
fields: ErrorResponse['error']['fields'];
details: ErrorResponse['error']['details'];
requestId?: string;

Expand All @@ -83,7 +82,7 @@ class ObjectStackError extends Error {
this.code = response.error.code;
this.status = response.error.httpStatus ?? 0;
this.retryAfter = response.error.retryAfter;
this.fieldErrors = response.error.fieldErrors;
this.fields = response.error.fields;
this.details = response.error.details;
this.requestId = response.error.requestId ?? response.meta?.requestId;
}
Expand Down Expand Up @@ -111,7 +110,7 @@ class ObjectStackError extends Error {
/** Extract field-level errors as a map of field path → message */
get fieldErrorMap(): Record<string, string> {
const errors: Record<string, string> = {};
for (const detail of this.fieldErrors ?? []) {
for (const detail of this.fields ?? []) {
if (detail.field) {
errors[detail.field] = detail.message;
}
Expand Down
29 changes: 17 additions & 12 deletions content/docs/api/error-handling-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ description: Best practices for handling and throwing errors in ObjectStack plug
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()`.

<Callout type="info">
**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.
**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.
</Callout>

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

type FieldError = NonNullable<ErrorResponse['error']['fieldErrors']>[number];
type FieldError = NonNullable<ErrorResponse['error']['fields']>[number];

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

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

return new AppError(
'VALIDATION_ERROR',
`Validation failed: ${fieldErrors.length} error(s)`,
`Validation failed: ${fields.length} error(s)`,
400,
fieldErrors,
fields,
);
}

Expand Down
3 changes: 2 additions & 1 deletion content/docs/references/api/errors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ const result = EnhancedApiError.parse(data);
| **retryStrategy** | `Enum<'no_retry' \| 'retry_immediate' \| 'retry_backoff' \| 'retry_after'>` | optional | Recommended retry strategy |
| **retryAfter** | `number` | optional | Seconds to wait before retrying |
| **details** | `any` | optional | Additional error context |
| **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) |
| **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 |
| **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. |
| **timestamp** | `string` | optional | When the error occurred |
| **requestId** | `string` | optional | Request ID for tracking |
| **traceId** | `string` | optional | Distributed trace ID |
Expand Down
59 changes: 59 additions & 0 deletions content/docs/releases/v17.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,65 @@ declares `ai.exposed: true` + `ai.description` **and** has a headless path —
warns on a stack declaring `stack.agents`, which the runtime has filtered from
the catalog since ADR-0063.

### Error codes are one SCREAMING_SNAKE vocabulary, and `error.code` is a closed set (ADR-0112, #3841)

The platform shipped two live error-code dialects: `StandardErrorCode` declared
lowercase `snake_case` members while 139 codes on the wire were SCREAMING_SNAKE,
and `ApiErrorSchema.code` was a bare `z.string()`, so nothing validated either.
Now there is one vocabulary — the standard catalog plus `ERROR_CODE_LEDGER` for
service-specific codes — and `error.code` validates against their union, so an
unregistered code fails parse instead of quietly becoming a new dialect.

**Wire-visible.** Codes change spelling on every surface that spoke lowercase.
Generic conditions collapse onto the standard catalog rather than keeping a
synonym: `unauthorized`/`unauthenticated` → `UNAUTHENTICATED`, `forbidden` →
`PERMISSION_DENIED`, `not_found` → `RESOURCE_NOT_FOUND`, `internal` →
`INTERNAL_ERROR`, `unavailable` → `SERVICE_UNAVAILABLE`, `not_supported` →
`NOT_IMPLEMENTED`, `bad_request` → `INVALID_REQUEST` (a status name is not a
semantic code). Domain conditions get a registered SCREAMING code.

**Migration:** branch on `error.code` **values**, and do not pattern-match their
case. A code branch that misses fails *silently* — nothing throws, the affordance
it guards just disappears — which is how eleven console branches broke without a
single test failing (objectui#2977). If you support servers on both sides of the
upgrade, compare case-insensitively; that is what the console does.

Four routes also stop putting a code in the message slot: the webhook redeliver
route, the API-trigger webhook and two `rest` routes answered
`{ success: false, error: '<code>', message }`. They now emit
`error: { code, message }`, so a client reading `body.error` as a string on those
routes must read `body.error.code`.

Not swept, deliberately: `sys_metadata_audit.code` (persisted audit history, and
the column also holds non-errors like `ok`), diagnostics records that ship inside
a 200, field-level codes (see below), and the CLI's `--json` output contract.

### Field-level error codes are their own closed catalog, and `fieldErrors` becomes `fields` (ADR-0114, #3977)

The per-field `code` inside `fields[]` is a **different** vocabulary from
`error.code`, and it stays lowercase on purpose: a top-level code names the
condition the *request* hit, while a field-level code 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.
`FieldErrorCode` closes that set (27 members) and `FieldErrorSchema.code`
validates against it, where it used to be `z.string()`.

**`EnhancedApiError.fieldErrors` is renamed to `fields`** — the name every
producer already emitted. The old key was declared and emitted by nobody, so a
reader keying on it was reading a field no server sent. It is tombstoned rather
than deleted: writing it now fails with the rename instead of parsing clean and
losing the array.

**Wire-visible.** Routes that validate with Zod stopped leaking Zod's issue codes:
`too_small` now becomes `min_length` / `min_value` / `min_items` depending on what
was too small, `unrecognized_keys` becomes `unknown_field`, and — the one that was
a real bug — a **missing** required property now reports `required` instead of the
`invalid_type` Zod uses for it, so a form marks it as missing rather than
wrong-typed.

**Migration:** read `error.fields`; branch on the catalog's lowercase codes for
per-field handling, and show `message` to the user rather than the code.

### Approval requests are visible to participants, not the whole tenant (#3590)

`getRequest` / `listRequests` / `countRequests` query with `SYSTEM_CTX` to
Expand Down
Loading
Loading