Skip to content

fieldsFromZodIssues leaks Zod's issue codes onto the wire's fields[].code — the exact pass-through ADR-0114 D3 closed, still live in @objectstack/runtime #8124

Description

@hotlong

Found while implementing #8055 (which emits through this helper and therefore inherits the defect).

What is declared

ADR-0114 D2 makes FieldErrorSchema.code a closed catalogFieldErrorCode in packages/spec/src/api/errors.zod.ts, 27 lowercase snake_case members. D3 is explicit that Zod is mapped at the boundary, never passed through:

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.

@objectstack/rest complies: zodIssuesToFields in rest-server.ts runs zodIssueToFieldCode, which is typed to return FieldErrorCode.

What is emitted

packages/runtime/src/validation-failure.ts has a second, non-compliant converter — fieldsFromZodIssues — which assigns the Zod issue's own code verbatim:

code: issue.code,

Measured against the real FlowSchema (a genuine safeParse, not a hand-written fixture), the codes that reach the wire include unrecognized_keysnot a FieldErrorCode member. ADR-0114 D3's own table says that row maps to unknown_field. Zod's too_small / too_big / custom / invalid_union are equally outside the catalog, and too_small is the ambiguity D3 names explicitly (a short string, a small number and a short array share one code).

Consequence: a response whose details.fields[] carries one of those codes does not parse against the schema the protocol declares for it. A client validating the error envelope — the thing ADR-0114 D2 gave it a schema for — rejects a well-formed refusal from its own server. It is also the two-vocabularies-on-one-position problem the ADR was written to remove: the same wire slot answers min_length from a validator and too_small from a Zod-parsed route, and nothing in the body says which dialect the reader is holding.

Affected call sites

Three, all in @objectstack/runtime, all through this one helper:

Handled as one card rather than three deliberately: the pass-through is the helper's, not any route's, and the fix belongs where they all read from. #8055 explicitly did not fork a local mapper for its own route, because a fourth spelling inside one package is worse than the shared gap.

Why the obvious fix is not a one-liner

zodIssuesToFields — the compliant implementation — is module-local to packages/rest/src/rest-server.ts and not exported from that package's index. Three routes worth weighing:

  1. Export it from @objectstack/rest. Cheapest edit, but it makes a ~11k-line server module part of runtime's import graph for one pure function, and puts a spec-vocabulary mapper's public home in the REST transport.
  2. Move the mapper to @objectstack/spec, next to the FieldErrorCode catalog it is total over, and have both rest and runtime read it. Most contract-first: one catalog, one mapping, defined where the vocabulary is defined. Largest diff.
  3. Reimplement in runtime. Rejected on sight — a second implementation of D3's table is the drift the ADR exists to prevent, and the two would disagree the first time Zod adds an issue code.

Recommendation is (2), but it is a real design call, so it wants a decision rather than a guess.

Note on measurement

ADR-0114 D3 says its mapping is "tested by driving realsafeParse calls, not by hand-written issue fixtures — which is how the input problem above surfaced at all". Whatever fix lands should be measured the same way; #8055's tests already drive the real FlowSchema, so they are one available fixture source.

Severity

Not judged here. It is a live wire-contract mismatch (declared schema vs. emitted value) on three routes, but no in-repo consumer branches on a field code today — objectui's extractFieldErrors reads field and message and touches code only as a last-resort fallback, which ADR-0114 measured before choosing the casing.


Generated by Claude Code

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions