Skip to content

feat(spec,rest,objectql)!: a closed field-level error catalog, and Zod stops leaking onto the wire (#3977) - #4035

Merged
os-zhuang merged 2 commits into
mainfrom
claude/error-code-vocabulary-mismatch-iscrkw
Jul 30, 2026
Merged

feat(spec,rest,objectql)!: a closed field-level error catalog, and Zod stops leaking onto the wire (#3977)#4035
os-zhuang merged 2 commits into
mainfrom
claude/error-code-vocabulary-mismatch-iscrkw

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Settles the vocabulary ADR-0112 D6 deferred, recorded as ADR-0114. Closes #3977 — the last open descendant of #3841.

The survey came first, and corrected two of the issue's premises

#3977 asked to survey consumers before choosing casing, because "form UIs may already string-match."

They don't. objectui has exactly one field-error consumer, extractFieldErrors in react/src/utils/error-message.ts; it reads field and message and touches code only as the last fallback in firstString(rec.message, rec.error, rec.code) — its own comment says an untranslated enum in the UI reads as a bug. The framework has no product-code branch at all; every match is a test asserting an emitter's output. So the casing choice was free, and could be made on principle rather than migration cost.

And it isn't "four dialects." Six emitters, 24 distinct codes, and the overlaps are semantically consistent — required means the same thing in all five places that emit it, as do max_length, min_value, invalid_option. What was missing was a schema, not a decision about which dialect wins.

What lands

FieldErrorCode — closed, 27 members, lowercase on purpose. Not an oversight against ADR-0112's SCREAMING_SNAKE: 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 the code and the property are the same word:

{ required: true }  → 'required'      { max_length: 50 } → 'max_length'

Going SCREAMING would sever that to buy consistency with a vocabulary D6 deliberately separated. No ledger tier either, unlike ADR-0112: a constraint kind is a property of the type system, so a service adds a member rather than inventing one.

Three declarations collapse into it. FieldErrorSchema.code tightens from z.string() to the enum; FieldValidationError.code (objectql) stops being a hand-listed literal union — the shape that bit batch 2 from two packages away — and FieldCoerceError.code (rest) stops being a bare string, which made its "shaped like the engine's validation errors" comment a claim rather than a type.

Zod stops reaching the wire. zodIssuesToFields passed Zod's codes through, so fields[] spoke a different language depending on which route served it. It now maps via origin/format — which also resolves the too_small ambiguity #3977 thought had no clean answer:

Was Now
too_small min_length / min_value / min_items
too_big max_length / max_value / max_items
invalid_format invalid_email / invalid_url / invalid_format
invalid_value invalid_option
unrecognized_keys unknown_field
invalid_union, invalid_element, invalid_key invalid_shape

A missing property now reports required, not invalid_type — a user-visible bug, not a rename. Zod spells "absent" as a type mismatch against undefined, so the passthrough made a form mark a missing input as the wrong type.

That one needed care. Missing and wrong-typed are byte-identical on the issue — v4 carries expected and a message but not the offending value — so the mapper takes the parsed input as an optional argument and walks the issue path. A caller that can't supply it keeps invalid_type rather than a guess: the only other signal is the message text ("received undefined"), and making the wire contract depend on Zod's phrasing is the leak this change removes.

It only surfaced because the tests drive real safeParse calls. The first draft of the mapping read issue.input; a real parse showed v4 issues don't carry it, so that branch could never have fired. A test built on a hand-written issue fixture would have passed.

Where I stopped short, deliberately

fieldErrorsfields is decided but not executed. fields is right — six emitters, the client and the console all say it; nothing has ever emitted fieldErrors. But it's an authorable key, and the contract guard in build-schemas.ts (#3733, ADR-0104) blocked the bare rename, correctly: these schemas aren't .strict(), so Zod silently strips an unknown key, and anyone still writing the old name gets a clean parse and a value that never takes effect. Doing it properly needs a retiredKey() tombstone, a D2 conversion so os migrate meta can rewrite consumers, and a major changeset with the FROM → TO mapping — a change of its own, not a rider on this one.

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 half that makes a validation body assertable — the element schema — lands here.

Six field-shaped members stay in the top-level catalog (INVALID_FIELD, MISSING_REQUIRED_FIELD, INVALID_FORMAT, VALUE_TOO_LONG, VALUE_TOO_SHORT, VALUE_OUT_OF_RANGE) — #3977's fourth "vocabulary." Removing a member breaks a vocabulary two batches just stabilised, and VALIDATION_ERROR + a fields[] entry is already the honest top-level answer. Consequence: invalid_format exists at both levels, which the catalog test admits as the single declared overlap — an unlisted overlap fails, because that's exactly how a field member gets added by copying a top-level one.

Two gates caught real drift

Both kept as evidence in comments rather than quietly fixed:

  • A live route's test (rest-bulk-path-object.test.ts) had pinned the invalid_type passthrough — so it was pinning a form marking a missing input as a type error. Its message still reads "received undefined", which is what the old code reported as a type error.
  • check:skill-examples (which compiles prose examples against spec) found two doc examples putting top-level concepts in a field-code position: lock_conflict on a field: 'status', and invalid_field for an invalid status transition. The catalog answers the second exactly — invalid_transition — and the first shouldn't have a field entry at all, since a terminal-state conflict is a condition of the record.

Test evidence

Suite Result
pnpm turbo run test (CI's command) 123/126 tasks ✅
dogfood (excluded from the above by design) 71 files / 410 ✅
spec 265 files / 6899 ✅
objectql 80 files / 1171 ✅
rest 35 files / 502 ✅
spec check gates + check:error-code-casing ✅ (2401 files, 0 violations)
check:skill-examples ✅ 198 examples

@objectstack/runtime carries one failure in datasource-autoconnect.test.ts (a federated query returning duplicates). Not from this PR — verified by checking out this commit's parent in the same worktree and reproducing it there; my diff touches no runtime file, and that test was last changed by #3893.

Closes #3977.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MaSQn77TT5fUgHK9CesaDK


Generated by Claude Code

…d 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
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 30, 2026 5:21am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests protocol:ui tooling size/l labels Jul 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/objectql, @objectstack/rest, @objectstack/spec.

110 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/connect-mcp.mdx (via @objectstack/rest)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/rest, @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/rest, @objectstack/spec)
  • content/docs/automation/approvals.mdx (via packages/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql, packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/objectql, @objectstack/rest, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/objectql, @objectstack/rest, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/rest, @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql, @objectstack/rest, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/rest, @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…l 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
@os-zhuang
os-zhuang marked this pull request as ready for review July 30, 2026 05:38
@os-zhuang
os-zhuang merged commit 7d7521f into main Jul 30, 2026
18 checks passed
@os-zhuang
os-zhuang deleted the claude/error-code-vocabulary-mismatch-iscrkw branch July 30, 2026 05:39
os-zhuang added a commit that referenced this pull request Jul 30, 2026
…114 D4, #3977) (#4055)

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

Completes the one thing #4035 decided but did not execute. The wire has always
carried `fields`; `fieldErrors` was declared and emitted by nobody, so a reader
keying on it was reading a field no server sent — ADR-0078's silently-inert
declaration, on the error envelope.

Tombstoned rather than deleted, because the schema is not .strict(): a plain
removal would let a producer still writing the old name parse clean and lose the
per-field detail, answering a validation failure that mentions no field.
retiredKey() turns that into a rejection carrying the rename.

Registered as a SEMANTIC chain entry, not a conversion, and the distinction is the
point. A conversion rewrites author metadata; this is a response envelope, and no
stack, example or template carries the key (checked across packages/, examples/,
templates/, apps/). A no-op conversion with an identity fixture would claim a
rewrite that does not exist. The analytics-query-request-* entries set the
precedent one step earlier in the same major: an HTTP-only surface with nothing
stored to rewrite is a semantic entry with a reason and an acceptance criterion —
which is what actually reaches spec-changes.json, the upgrade guide and the
spec_changes MCP tool.

Docs followed, and two examples were teaching the opposite of this ADR: the
server guide hand-rolled a Zod mapping with code: 'invalid_field' — a top-level
code in a field position — and the client guide's error class carried the dead
name. The server example now calls zodIssuesToFields, which is the mapping the
platform's own routes use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MaSQn77TT5fUgHK9CesaDK

* docs(releases): v17 breaking-changes gets the error-code vocabulary entries it was missing

The v17 page documents each breaking change under its own heading, and the whole
ADR-0112 line had none — the largest wire-visible change in it appeared only as a
passing mention 600 lines down. That is the worst gap to leave, because a missed
error-code branch fails SILENTLY: nothing throws, the affordance it guarded just
disappears. Eleven console branches broke that way without one test failing.

Two entries: ADR-0112's vocabulary unification (with the generic-condition
collapse table, the four routes that stopped putting a code in the message slot,
and the case-insensitive advice for consumers spanning the upgrade), and
ADR-0114's field-level catalog plus this PR's fieldErrors -> fields rename.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MaSQn77TT5fUgHK9CesaDK

---------

Co-authored-by: Claude <noreply@anthropic.com>
baozhoutao added a commit that referenced this pull request Jul 30, 2026
…DR-0114's catalog

`main` landed ADR-0114 (#4035), which gave the field level its own closed code
catalog — the same convergence this branch had reached independently. ADR-0114's
version is the one that stays; #3957 is rebuilt on top of it rather than beside
it (Prime Directive #12 — one contract, not two dialects):

- Deleted `spec/src/data/validation-error.zod.ts` wholesale. Its `FieldValidationCode`
  duplicated `FieldErrorCode`, and its `FieldValidationErrorSchema` duplicated
  `FieldErrorSchema`. The generated snapshots (json-schema manifest, authorable +
  API surface) were reset to main's and regenerated, so nothing of the parallel
  contract survives.
- `FieldErrorSchema` instead gains the one field it lacked: `label`. The
  constraint payload rides its EXISTING `constraint` position (typed from
  `unknown` to `Record<string, unknown>`) and the offending value rides `value`
  — so `params` is gone as a concept. The message templates interpolate from
  those same keys.
- `objectql`'s `FieldValidationError` keeps main's `code: FieldErrorCode` and
  adds `label` / `constraint` / `value`.

Two messages that arrived on main in the meantime were localized too, or the fix
would have shipped with fresh holes in it:

- ADR-0113's clear-out rejection (`X is required and cannot be cleared`) — new
  catalog key `required_cleared`, one wire code (`required`), four locales.
- #3956's import dry-run bound pre-check (`firstConstraintViolation`) — it had
  reintroduced `penalty_amount must be ≥ 0` verbatim. It now renders from the
  shared catalog, which is also what keeps its "same verdict, same message as
  the real write" promise true after localization.

Test expectations updated where the label now replaces the API name; the ADR-0113
`requiredWhen` pre-state check and the #3956 dry-run tests from main are kept as
they were, only their messages re-pinned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:ui size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Field-level error codes are four vocabularies with no schema — needs its own catalog (ADR-0112 D6 follow-up)

2 participants