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 catalog — FieldErrorCode 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:
Measured against the real FlowSchema (a genuine safeParse, not a hand-written fixture), the codes that reach the wire include unrecognized_keys — not 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:
- 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. - 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. - 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
Found while implementing #8055 (which emits through this helper and therefore inherits the defect).
What is declared
ADR-0114 D2 makes
FieldErrorSchema.codea closed catalog —FieldErrorCodeinpackages/spec/src/api/errors.zod.ts, 27 lowercase snake_case members. D3 is explicit that Zod is mapped at the boundary, never passed through:@objectstack/restcomplies:zodIssuesToFieldsinrest-server.tsrunszodIssueToFieldCode, which is typed to returnFieldErrorCode.What is emitted
packages/runtime/src/validation-failure.tshas a second, non-compliant converter —fieldsFromZodIssues— which assigns the Zod issue's own code verbatim:Measured against the real
FlowSchema(a genuinesafeParse, not a hand-written fixture), the codes that reach the wire includeunrecognized_keys— not aFieldErrorCodemember. ADR-0114 D3's own table says that row maps tounknown_field. Zod'stoo_small/too_big/custom/invalid_unionare equally outside the catalog, andtoo_smallis 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 answersmin_lengthfrom a validator andtoo_smallfrom 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:src/domains/analytics.ts— theAnalyticsQueryRequestSchemaentry refusalsrc/domains/notifications.ts— the same shape one domain oversrc/domains/automation.ts— the flow-registration refusal added byPOST /api/v1/automationanswers 500 INTERNAL_ERROR for a malformed flow body — including the #4277 undeclared-config-key refusal the #7545 ruling leans on #8055Handled 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 topackages/rest/src/rest-server.tsand not exported from that package's index. Three routes worth weighing:@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.@objectstack/spec, next to theFieldErrorCodecatalog it is total over, and have bothrestandruntimeread it. Most contract-first: one catalog, one mapping, defined where the vocabulary is defined. Largest diff.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 real
safeParsecalls, not by hand-written issue fixtures — which is how theinputproblem above surfaced at all". Whatever fix lands should be measured the same way; #8055's tests already drive the realFlowSchema, 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
extractFieldErrorsreadsfieldandmessageand touchescodeonly as a last-resort fallback, which ADR-0114 measured before choosing the casing.Generated by Claude Code