From 10c96564e296cb7ffd72de1f8a4846972cfbc486 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 07:12:04 +0000 Subject: [PATCH 1/2] feat(spec)!: EnhancedApiError.fieldErrors -> fields, tombstoned (ADR-0114 D4, #3977) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01MaSQn77TT5fUgHK9CesaDK --- .changeset/adr-0114-field-errors-rename.md | 30 ++++++++++++++ content/docs/api/error-catalog.mdx | 34 ++++++++-------- content/docs/api/error-handling-client.mdx | 13 +++---- content/docs/api/error-handling-server.mdx | 29 ++++++++------ content/docs/references/api/errors.mdx | 3 +- .../0114-field-level-error-code-catalog.md | 18 ++++----- docs/protocol-upgrade-guide.md | 3 ++ packages/spec/authorable-surface.json | 3 +- packages/spec/spec-changes.json | 14 +++++++ packages/spec/src/api/errors.test.ts | 39 ++++++++++++++++--- packages/spec/src/api/errors.zod.ts | 33 ++++++++++------ packages/spec/src/migrations/registry.ts | 17 ++++++++ 12 files changed, 170 insertions(+), 66 deletions(-) create mode 100644 .changeset/adr-0114-field-errors-rename.md diff --git a/.changeset/adr-0114-field-errors-rename.md b/.changeset/adr-0114-field-errors-rename.md new file mode 100644 index 0000000000..7b61bb2ff2 --- /dev/null +++ b/.changeset/adr-0114-field-errors-rename.md @@ -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. diff --git a/content/docs/api/error-catalog.mdx b/content/docs/api/error-catalog.mdx index 8af2431f69..4abe4c6a90 100644 --- a/content/docs/api/error-catalog.mdx +++ b/content/docs/api/error-catalog.mdx @@ -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 @@ -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" } ] } @@ -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` @@ -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 @@ -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. - -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). + +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`. --- @@ -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 @@ -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 { @@ -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); }); diff --git a/content/docs/api/error-handling-client.mdx b/content/docs/api/error-handling-client.mdx index 8b38fb306e..0f02b2c2eb 100644 --- a/content/docs/api/error-handling-client.mdx +++ b/content/docs/api/error-handling-client.mdx @@ -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. @@ -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; @@ -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; } @@ -111,7 +110,7 @@ class ObjectStackError extends Error { /** Extract field-level errors as a map of field path → message */ get fieldErrorMap(): Record { const errors: Record = {}; - for (const detail of this.fieldErrors ?? []) { + for (const detail of this.fields ?? []) { if (detail.field) { errors[detail.field] = detail.message; } diff --git a/content/docs/api/error-handling-server.mdx b/content/docs/api/error-handling-server.mdx index 825ae8e144..08c906d03b 100644 --- a/content/docs/api/error-handling-server.mdx +++ b/content/docs/api/error-handling-server.mdx @@ -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()`. -**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. --- @@ -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`). @@ -27,7 +27,7 @@ SCREAMING_SNAKE (ADR-0112): a `StandardErrorCode` member (e.g. `VALIDATION_ERROR ```typescript import type { ErrorResponse, StandardErrorCode } from '@objectstack/spec/api'; -type FieldError = NonNullable[number]; +type FieldError = NonNullable[number]; // Domain helper: a throwable error that maps cleanly onto EnhancedApiError. class AppError extends Error { @@ -35,7 +35,7 @@ class AppError extends Error { public code: StandardErrorCode, message: string, public httpStatus: number, - public fieldErrors?: FieldError[], + public fields?: FieldError[], ) { super(message); this.name = 'AppError'; @@ -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, ); } diff --git a/content/docs/references/api/errors.mdx b/content/docs/references/api/errors.mdx index 451719b2d9..10fe4f4559 100644 --- a/content/docs/references/api/errors.mdx +++ b/content/docs/references/api/errors.mdx @@ -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 | diff --git a/docs/adr/0114-field-level-error-code-catalog.md b/docs/adr/0114-field-level-error-code-catalog.md index a180237749..d51c2d7a03 100644 --- a/docs/adr/0114-field-level-error-code-catalog.md +++ b/docs/adr/0114-field-level-error-code-catalog.md @@ -2,7 +2,7 @@ **Status**: Accepted (2026-07-30) **Deciders**: ObjectStack Protocol Architects -**Builds on**: [ADR-0112](./0112-error-code-vocabulary-and-ledger.md) (D6 deferred exactly this decision, and its catalog+ledger shape is the model reused here), [ADR-0049](./0049-no-unenforced-security-properties.md) (declare-and-enforce — the wire `fields[]` element currently has no schema at all), [ADR-0078](./0078-no-silently-inert-metadata.md) (no silently inert declarations — `EnhancedApiErrorSchema.fieldErrors` is declared and never emitted), [ADR-0104](./0104-field-runtime-value-shape-contract.md) (the silently-stripped-key line its contract guard enforces — why D4's rename is deferred rather than done here) +**Builds on**: [ADR-0112](./0112-error-code-vocabulary-and-ledger.md) (D6 deferred exactly this decision, and its catalog+ledger shape is the model reused here), [ADR-0049](./0049-no-unenforced-security-properties.md) (declare-and-enforce — the wire `fields[]` element had no schema at all), [ADR-0078](./0078-no-silently-inert-metadata.md) (no silently inert declarations — `EnhancedApiErrorSchema.fieldErrors` was declared and never emitted), [ADR-0104](./0104-field-runtime-value-shape-contract.md) (the silently-stripped-key line its contract guard enforces — why D4's rename is tombstoned rather than deleted) **Consumers**: `@objectstack/spec` (`api/errors.zod.ts`, `ui/action-params.zod.ts`), `@objectstack/objectql` (`validation/record-validator.ts`, `validation/rule-validator.ts`), `@objectstack/rest` (`import-coerce.ts`, `import-runner.ts`, `zodIssuesToFields`), `@objectstack/plugin-sharing` (`rule-criteria.ts`), `@objectstack/runtime` (`validation-failure.ts`), `@objectstack/client`, objectui (`react/src/utils/error-message.ts` — the only console consumer) **Surfaced by**: [#3977](https://github.com/objectstack-ai/objectstack/issues/3977), split out of [#3841](https://github.com/objectstack-ai/objectstack/issues/3841) by ADR-0112 D6. @@ -12,7 +12,7 @@ A field-level code names **which constraint the value violated**, and constraints are already named in the metadata's own snake_case vocabulary — `required: true`, `max_length: 50`, `min_value: 0`. So the field vocabulary stays **lowercase snake_case**, deliberately *not* following ADR-0112's SCREAMING_SNAKE, because here the code is a constraint name and the correspondence with the schema property is the point. -It becomes a **closed catalog** (`FieldErrorCode` in spec, 27 members) that `FieldErrorSchema.code` validates against, and Zod's issue codes get **mapped at the `zodIssuesToFields` boundary** instead of leaking library internals onto the wire. The `fields`-vs-`fieldErrors` naming split is decided (`fields` is right) but **not executed**: it is an authorable-key retirement, so ADR-0104's tombstone-plus-migration machinery applies and gets its own change. +It becomes a **closed catalog** (`FieldErrorCode` in spec, 27 members) that `FieldErrorSchema.code` validates against, and Zod's issue codes get **mapped at the `zodIssuesToFields` boundary** instead of leaking library internals onto the wire. The declared envelope also stops calling the array `fieldErrors`: it is `fields`, the name every producer already emitted, with the old key **tombstoned** rather than deleted. ## Context @@ -65,7 +65,7 @@ That last row is a real bug, not a tidy-up: Zod reports a missing required prope It is also the one case `origin`/`format` cannot settle. A v4 issue carries `expected` and a message but **not the offending value**, so a missing property and a wrong-typed one are byte-identical on the issue — same `code`, same `expected`, same keys. The only other signal is the message text ("received undefined"), and depending on Zod's phrasing for a wire contract is precisely the leak this decision removes. So the discriminator is the **parsed input**, walked to the issue's `path`: the mapper takes it as an optional argument, and a caller that cannot supply it gets the accurate-but-less-specific `invalid_type` rather than a guess. -### `fieldErrors` is declared and never emitted +### `fieldErrors` was declared and never emitted The wire carries `fields` — `runtime/src/validation-failure.ts`, all six emitters, `@objectstack/client`, and objectui's extractor all say `fields`. `EnhancedApiErrorSchema` declares `fieldErrors`, which nothing emits and nothing reads. That is ADR-0078's silently-inert declaration, on the error envelope. @@ -77,9 +77,11 @@ The wire carries `fields` — `runtime/src/validation-failure.ts`, all six emitt **D3 — Zod is mapped at the boundary, never passed through.** `zodIssuesToFields` translates using `origin` / `format` per the table above, plus the parsed input for the `invalid_type` split. An unmapped Zod code becomes `invalid_value` (a catalog member) rather than leaking. The mapping is tested by driving **real** `safeParse` calls, not by hand-written issue fixtures — which is how the `input` problem above surfaced at all: the first draft read `issue.input`, and a real parse showed that branch could never fire. -**D4 — The wire's `fields` is the right name, and the rename is deferred with its cost written down.** `EnhancedApiErrorSchema.fieldErrors` should become `fields`; nothing has ever emitted `fieldErrors`, so the declaration points away from reality. But it is an **authorable key**, and the contract guard in `build-schemas.ts` (#3733, ADR-0104) rightly blocks a bare rename: these schemas are not `.strict()`, so Zod silently strips an unknown key, and an author or producer still writing the old name would get a clean parse and a value that never takes effect. Retiring it properly needs a `retiredKey()` tombstone carrying the fix, a D2 conversion (and D3 chain step) so `os migrate meta` can rewrite consumers, and a major changeset with the FROM → TO mapping. +**D4 — The wire's `fields` wins, and `fieldErrors` is retired the way ADR-0104 requires.** `EnhancedApiErrorSchema` now declares `fields`; nothing ever emitted `fieldErrors`, so the old declaration pointed away from reality (ADR-0078's silently-inert declaration, on the error envelope). -That is a change of its own, not a rider on this one. What lands here is the half that makes a validation body **assertable** — the element schema, with `code` closed — and the property keeps its name plus a banner naming the mismatch and the machinery its retirement needs. Declaring the wrong name loudly beats renaming it quietly. +The old key is **tombstoned, not deleted**. The contract guard in `build-schemas.ts` (#3733, ADR-0104) is right to block a bare rename: these schemas are not `.strict()`, so Zod silently strips an unknown key — a producer still writing `fieldErrors` would 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. + +The change reaches the documentation channel as a **semantic** chain entry, not a conversion, and that distinction is the point rather than a shortcut. A conversion's job is rewriting author *metadata*; this is a response envelope, and no stack, example or template has ever carried the key (verified 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 reaches `spec-changes.json`, the generated upgrade guide, and the `spec_changes` MCP tool. **D5 — What this catalog does NOT govern, restated from ADR-0112.** The three neighbouring vocabularies stay put: persisted columns (D6b), diagnostics inside a 200 (D6c), and the top-level catalog. The line from ADR-0112 holds — *the field catalog governs the code that names which constraint a value violated*. `check-error-code-casing.mjs` already recognises the field-addressed shape structurally, so this catalog needs no new guard exemptions; what it needs, and now gets, is a schema. @@ -98,11 +100,9 @@ That is a change of its own, not a rider on this one. What lands here is the hal - `FieldErrorSchema.code` tightens from `z.string()` to `FieldErrorCode`, and ADR-0112's banner comment comes off. The conformance suites that parse error bodies gain a value check for free. - The wire `fields[]` element has a schema for the first time, so a validation response can be asserted structurally rather than by duck-typing. - Zod-served routes change their field codes (`too_small` → `min_length`, and a missing property stops reporting as a type error). This is a wire-visible fix; nothing in-repo or in the console branches on the old values. -- `EnhancedApiErrorSchema.fieldErrors` keeps a name the wire does not use, now with a banner saying so. That is a deliberate debt: loud and documented beats a quiet rename that strips an author's key. +- `EnhancedApiErrorSchema.fieldErrors` is gone as a writable key: constructing one with the old name now fails to parse, carrying the rename. Anyone reading `error.fieldErrors` was reading a field no server sent, so the read has no behaviour to preserve — but the failure is now loud at the write side instead of silent at the read side. - Adding a constraint kind now means adding a catalog member, which is the intended friction: a new *kind* of constraint is a type-system change and deserves a line in the spec. ## Rollout -One PR — the emitters already speak the chosen vocabulary, so the change is the catalog, the schema tightening, and the Zod mapping. No sweep, no batches, no consumer migration. - -Deferred to its own change (D4): retiring `fieldErrors` for `fields`, which needs the ADR-0104 sequence — tombstone, D2 conversion + D3 chain step, major changeset. +Two PRs. The first (#4035) landed the catalog, the schema tightening and the Zod mapping — the emitters already spoke the chosen vocabulary, so there was no sweep and no consumer migration. The second lands D4's rename with the ADR-0104 sequence: `retiredKey()` tombstone, a D3 semantic chain entry (not a conversion — see D4), and a major changeset carrying FROM → TO. diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 279d0aecc4..d498a8c526 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -153,6 +153,9 @@ On the wire contract it also retires the `/analytics/query` request ENVELOPE (#3 - **`analytics-query-request-envelope-retired`** — `api.analyticsQueryRequest.query` → bare AnalyticsQuery body (top-level cube/measures/dimensions/where/...) - Why not automatic: The { cube, query: {...} } envelope was an HTTP-wire dialect of the retired degraded analytics shim (#3891), never stored in stack metadata — there is no source for the chain to rewrite. Callers of POST /analytics/query and /analytics/sql must move the query.* fields to the body top level themselves. - Done when: Every /analytics/query and /analytics/sql call sends the bare AnalyticsQuery shape and succeeds; no request answers 400 VALIDATION_FAILED with the envelope prescription. +- **`enhanced-api-error-field-errors-renamed`** — `api.enhancedApiError.fieldErrors` → fields + - Why not automatic: 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`, and nothing ever emitted `fieldErrors`, so a reader keying on it was reading a field no server sent (ADR-0078's silently-inert declaration, on the error envelope). This is a RESPONSE surface: no stack, example or template carries the key, so there is no source for the chain to rewrite — the schema tombstones it via retiredKey() and consumers move their read themselves. ADR-0114 D4, #3977. + - Done when: No consumer reads `error.fieldErrors`; per-field validation detail is read from `error.fields`, and constructing an EnhancedApiError with `fieldErrors` fails to parse with the rename prescription instead of silently losing the array. - **`analytics-query-request-format-retired`** — `api.analyticsQueryRequest.format` → (removed — responses are always the JSON envelope; use the export surface for CSV/XLSX) - Why not automatic: The `format` key was declared but never implemented (declared ≠ enforced): every response is the JSON envelope regardless of the requested value, so there is no behaviour to preserve and nothing stored to rewrite. - Done when: No /analytics/query or /analytics/sql call sends `format`; exports go through the export surface. diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 9f5d4883a6..6efffb9130 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -970,7 +970,8 @@ "api/EnhancedApiError:code", "api/EnhancedApiError:details", "api/EnhancedApiError:documentation", - "api/EnhancedApiError:fieldErrors", + "api/EnhancedApiError:fieldErrors [RETIRED]", + "api/EnhancedApiError:fields", "api/EnhancedApiError:helpText", "api/EnhancedApiError:httpStatus", "api/EnhancedApiError:message", diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 6d2827666c..461564e61e 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -213,6 +213,13 @@ "toMajor": 17, "rationale": "The { cube, query: {...} } envelope was an HTTP-wire dialect of the retired degraded analytics shim (#3891), never stored in stack metadata — there is no source for the chain to rewrite. Callers of POST /analytics/query and /analytics/sql must move the query.* fields to the body top level themselves." }, + { + "surface": "api.enhancedApiError.fieldErrors", + "replacement": "fields", + "migrationId": "enhanced-api-error-field-errors-renamed", + "toMajor": 17, + "rationale": "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`, and nothing ever emitted `fieldErrors`, so a reader keying on it was reading a field no server sent (ADR-0078's silently-inert declaration, on the error envelope). This is a RESPONSE surface: no stack, example or template carries the key, so there is no source for the chain to rewrite — the schema tombstones it via retiredKey() and consumers move their read themselves. ADR-0114 D4, #3977." + }, { "surface": "api.analyticsQueryRequest.format", "replacement": "(removed — responses are always the JSON envelope; use the export surface for CSV/XLSX)", @@ -491,6 +498,13 @@ "toMajor": 17, "rationale": "The { cube, query: {...} } envelope was an HTTP-wire dialect of the retired degraded analytics shim (#3891), never stored in stack metadata — there is no source for the chain to rewrite. Callers of POST /analytics/query and /analytics/sql must move the query.* fields to the body top level themselves." }, + { + "surface": "api.enhancedApiError.fieldErrors", + "replacement": "fields", + "migrationId": "enhanced-api-error-field-errors-renamed", + "toMajor": 17, + "rationale": "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`, and nothing ever emitted `fieldErrors`, so a reader keying on it was reading a field no server sent (ADR-0078's silently-inert declaration, on the error envelope). This is a RESPONSE surface: no stack, example or template carries the key, so there is no source for the chain to rewrite — the schema tombstones it via retiredKey() and consumers move their read themselves. ADR-0114 D4, #3977." + }, { "surface": "api.analyticsQueryRequest.format", "replacement": "(removed — responses are always the JSON envelope; use the export surface for CSV/XLSX)", diff --git a/packages/spec/src/api/errors.test.ts b/packages/spec/src/api/errors.test.ts index b15a2c2bfe..f77bc95146 100644 --- a/packages/spec/src/api/errors.test.ts +++ b/packages/spec/src/api/errors.test.ts @@ -116,11 +116,9 @@ describe('EnhancedApiErrorSchema', () => { retryable: false, retryStrategy: 'no_retry', details: { count: 2 }, - // Still `fieldErrors` — the wire says `fields`, but retiring an authorable - // key needs ADR-0104's tombstone machinery, so ADR-0114 D4 defers it. The - // code below IS fixed: the field-level catalog's lowercase `invalid_email`, - // where this block used to assert a top-level SCREAMING member. - fieldErrors: [ + // `fields` — the name every producer already emitted. `fieldErrors` is + // tombstoned (ADR-0114 D4); the case below asserts that it now rejects. + fields: [ { field: 'email', code: 'invalid_email', @@ -136,7 +134,7 @@ describe('EnhancedApiErrorSchema', () => { expect(error.category).toBe('validation'); expect(error.httpStatus).toBe(400); - expect(error.fieldErrors).toHaveLength(1); + expect(error.fields).toHaveLength(1); expect(error.documentation).toContain('objectstack.dev'); }); @@ -334,3 +332,32 @@ describe('FieldErrorCode', () => { expect(() => FieldErrorCode.parse('VALIDATION_ERROR')).toThrow(); }); }); + +describe('EnhancedApiErrorSchema.fieldErrors retirement (ADR-0114 D4)', () => { + it('rejects the old name instead of silently dropping the array', () => { + // The whole reason for a tombstone rather than a delete: this schema is not + // `.strict()`, so a plain removal would let the write parse clean and lose + // the per-field detail — a validation response that mentions no field. + const attempt = () => EnhancedApiErrorSchema.parse({ + code: 'VALIDATION_ERROR', + message: 'Validation failed', + fieldErrors: [{ field: 'email', code: 'invalid_email', message: 'nope' }], + }); + expect(attempt).toThrow(); + // …and the rejection carries the fix, not just "invalid". + try { + attempt(); + } catch (e) { + expect(String(e)).toContain('fields'); + } + }); + + it('accepts the new name', () => { + const parsed = EnhancedApiErrorSchema.parse({ + code: 'VALIDATION_ERROR', + message: 'Validation failed', + fields: [{ field: 'email', code: 'invalid_email', message: 'nope' }], + }); + expect(parsed.fields).toHaveLength(1); + }); +}); diff --git a/packages/spec/src/api/errors.zod.ts b/packages/spec/src/api/errors.zod.ts index 234f37b487..0c40971242 100644 --- a/packages/spec/src/api/errors.zod.ts +++ b/packages/spec/src/api/errors.zod.ts @@ -26,6 +26,7 @@ import { z } from 'zod'; * High-level categorization of errors */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const ErrorCategory = z.enum([ 'validation', // Input validation errors (400) 'authentication', // Authentication failures (401) @@ -302,7 +303,7 @@ export type FieldError = z.infer; * "retryable": false, * "retryStrategy": "no_retry", * "details": { - * "fieldErrors": [ + * "fields": [ * { * "field": "email", * "code": "invalid_format", @@ -351,18 +352,26 @@ export const EnhancedApiErrorSchema = lazySchema(() => z.object({ /** * One entry per offending value. * - * ⚠️ NAME MISMATCH, deliberately left standing (ADR-0114 D4). The wire carries - * `fields` — the validators, import coercion, `validation-failure.ts`, - * `@objectstack/client` and the console's extractor all say `fields`, and - * nothing has ever emitted `fieldErrors`. Renaming it here is the right end - * state, but it is an authorable-key retirement: ADR-0104's contract guard - * requires a `retiredKey()` tombstone, a D2 conversion so `os migrate meta` can - * rewrite consumers, and a major changeset carrying the FROM → TO mapping. - * That machinery is a change of its own, not a rider on this one — ADR-0114 - * lands the ELEMENT schema (which is what makes a validation body assertable) - * and defers the rename with its cost written down. + * Named `fields` because that is what the wire has always carried — the + * validators, import coercion, `validation-failure.ts`, `@objectstack/client` + * and the console's field-error extractor all say `fields`. This property was + * declared as `fieldErrors` and emitted by nothing, which is ADR-0078's + * silently-inert declaration sitting on the error envelope; the rename points + * the declaration at reality rather than the reverse (ADR-0114 D4). */ - fieldErrors: z.array(FieldErrorSchema).optional().describe('Field-specific validation errors (wire name is `fields` — see ADR-0114 D4)'), + fields: z.array(FieldErrorSchema).optional().describe('One entry per offending value'), + /** + * Tombstoned, not deleted (ADR-0104): `EnhancedApiErrorSchema` is not + * `.strict()`, so a plain deletion would let anyone still writing `fieldErrors` + * parse clean and lose the array — the silent-strip shape ADR-0114 D4 refused + * to ship. `retiredKey()` turns that into a rejection carrying the fix. + */ + fieldErrors: retiredKey( + '`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: z.string().datetime().optional().describe('When the error occurred'), requestId: z.string().optional().describe('Request ID for tracking'), traceId: z.string().optional().describe('Distributed trace ID'), diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 3228070a42..50ec356474 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -439,6 +439,23 @@ const step17: MigrationStep = { 'Every /analytics/query and /analytics/sql call sends the bare AnalyticsQuery shape and ' + 'succeeds; no request answers 400 VALIDATION_FAILED with the envelope prescription.', }, + { + id: 'enhanced-api-error-field-errors-renamed', + surface: 'api.enhancedApiError.fieldErrors', + replacement: 'fields', + reason: + '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`, and nothing ever emitted `fieldErrors`, so a reader keying on it ' + + 'was reading a field no server sent (ADR-0078\'s silently-inert declaration, on the ' + + 'error envelope). This is a RESPONSE surface: no stack, example or template carries ' + + 'the key, so there is no source for the chain to rewrite — the schema tombstones it ' + + 'via retiredKey() and consumers move their read themselves. ADR-0114 D4, #3977.', + acceptanceCriteria: + 'No consumer reads `error.fieldErrors`; per-field validation detail is read from ' + + '`error.fields`, and constructing an EnhancedApiError with `fieldErrors` fails to parse ' + + 'with the rename prescription instead of silently losing the array.', + }, { id: 'analytics-query-request-format-retired', surface: 'api.analyticsQueryRequest.format', From 3ada47da8016467860079fc0d18a2f26dd3f1039 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 07:17:49 +0000 Subject: [PATCH 2/2] docs(releases): v17 breaking-changes gets the error-code vocabulary entries it was missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01MaSQn77TT5fUgHK9CesaDK --- content/docs/releases/v17.mdx | 59 +++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/content/docs/releases/v17.mdx b/content/docs/releases/v17.mdx index 1b6355f563..68d25a2f6d 100644 --- a/content/docs/releases/v17.mdx +++ b/content/docs/releases/v17.mdx @@ -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: '', 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