feat(spec,rest,objectql)!: a closed field-level error catalog, and Zod stops leaking onto the wire (#3977) - #4035
Merged
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 110 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…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
marked this pull request as ready for review
July 30, 2026 05:38
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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,
extractFieldErrorsinreact/src/utils/error-message.ts; it readsfieldandmessageand touchescodeonly as the last fallback infirstString(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 —
requiredmeans the same thing in all five places that emit it, as domax_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: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.codetightens fromz.string()to the enum;FieldValidationError.code(objectql) stops being a hand-listed literal union — the shape that bit batch 2 from two packages away — andFieldCoerceError.code(rest) stops being a barestring, which made its "shaped like the engine's validation errors" comment a claim rather than a type.Zod stops reaching the wire.
zodIssuesToFieldspassed Zod's codes through, sofields[]spoke a different language depending on which route served it. It now maps viaorigin/format— which also resolves thetoo_smallambiguity #3977 thought had no clean answer:too_smallmin_length/min_value/min_itemstoo_bigmax_length/max_value/max_itemsinvalid_formatinvalid_email/invalid_url/invalid_formatinvalid_valueinvalid_optionunrecognized_keysunknown_fieldinvalid_union,invalid_element,invalid_keyinvalid_shapeA missing property now reports
required, notinvalid_type— a user-visible bug, not a rename. Zod spells "absent" as a type mismatch againstundefined, 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
expectedand 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 keepsinvalid_typerather 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
safeParsecalls. The first draft of the mapping readissue.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
fieldErrors→fieldsis decided but not executed.fieldsis right — six emitters, the client and the console all say it; nothing has ever emittedfieldErrors. But it's an authorable key, and the contract guard inbuild-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 aretiredKey()tombstone, a D2 conversion soos migrate metacan 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, andVALIDATION_ERROR+ afields[]entry is already the honest top-level answer. Consequence:invalid_formatexists 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:
rest-bulk-path-object.test.ts) had pinned theinvalid_typepassthrough — 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_conflicton afield: 'status', andinvalid_fieldfor 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
pnpm turbo run test(CI's command)check:error-code-casingcheck:skill-examples@objectstack/runtimecarries one failure indatasource-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