From 17f93d17f93b4b32914b53dff22ccf335cb644e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 04:38:52 +0000 Subject: [PATCH 1/3] fix(spec,types,rest,runtime): move the ADR-0114 D3 mapper to @objectstack/spec and map fieldsFromZodIssues through it (#8124) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Euoy6wyfzgiWtgCg4s6JK2 --- content/docs/api/error-handling-server.mdx | 2 +- packages/rest/src/rest-server.ts | 310 +--------------- packages/runtime/src/domains/analytics.ts | 5 +- packages/runtime/src/domains/automation.ts | 16 +- packages/spec/src/api/index.ts | 4 + .../spec/src/api/zod-issues-to-fields.test.ts | 129 +++++++ packages/spec/src/api/zod-issues-to-fields.ts | 342 ++++++++++++++++++ packages/types/src/validation-failure.test.ts | 97 +++++ packages/types/src/validation-failure.ts | 36 +- 9 files changed, 631 insertions(+), 310 deletions(-) create mode 100644 packages/spec/src/api/zod-issues-to-fields.test.ts create mode 100644 packages/spec/src/api/zod-issues-to-fields.ts create mode 100644 packages/types/src/validation-failure.test.ts diff --git a/content/docs/api/error-handling-server.mdx b/content/docs/api/error-handling-server.mdx index 622b78a5dc..43dc0d6461 100644 --- a/content/docs/api/error-handling-server.mdx +++ b/content/docs/api/error-handling-server.mdx @@ -165,7 +165,7 @@ 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'; +import { zodIssuesToFields } from '@objectstack/spec/api'; // Map a ZodError onto an AppError carrying field-level errors. // diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 2f5539da22..6b15c10ced 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -55,7 +55,6 @@ import { refuseUnknownQueryParams } from './query-allowlist.js'; import type { DirectMountedRoute, MountedRouteSource } from './direct-mount.js'; import { RestServerConfig, RestApiConfig, CrudEndpointsConfig, MetadataEndpointsConfig, BatchEndpointsConfig, RouteGenerationConfig } from '@objectstack/spec/api'; import { DataProtocol, MetadataProtocol } from '@objectstack/spec/api'; -import type { FieldErrorCode } from '@objectstack/spec/api'; // [#8073] The closed ADR-0112 error vocabulary, so the explain family's single // refusal emitter types its `code` parameter as the vocabulary rather than as // `string` — an invented code is a compile error at the call site instead of a @@ -170,303 +169,22 @@ async function isTranslatableMetaType(type: string): Promise { * returns a misleading 404. */ /** - * A Zod issue → the field-level catalog (ADR-0114 D3). + * The ADR-0114 D3 mapper — Zod issue codes → the closed `FieldErrorCode` + * catalog, with the #5014 union-branch expansion — lived here module-locally + * until #8124 moved it to `@objectstack/spec` (`api/zod-issues-to-fields.ts`), + * beside the catalog it is total over. The move exists because + * `@objectstack/types`' `fieldsFromZodIssues` (the helper the runtime domain + * routes emit through) was still passing `issue.code` through raw, and `types` + * cannot import this package to share the compliant copy — the dependency + * arrow points rest → types, never back. One implementation of D3's table in + * the repo; a second is the drift the ADR exists to prevent. * - * Zod's issue codes are Zod's API, not ours, and this used to pass them straight - * through. Two things were wrong with that. The wire carried two vocabularies on - * one position — `too_small` from a route that parses with Zod, `min_length` from - * the validators — so a client could not read a field code without knowing which - * route served it. And Zod's own codes are ambiguous alone: `too_small` covers a - * short string, a small number AND a short array. - * - * `origin` and `format` disambiguate every case, so the mapping is total rather - * than best-effort. The one row that fixes a user-visible bug rather than tidying - * a name: Zod reports a MISSING required property as `invalid_type` (expected - * string, received undefined), so passing it through marked a missing input as a - * type error. - */ -function zodIssueToFieldCode(issue: any, path: unknown, input?: unknown, inputProvided = false): FieldErrorCode { - const origin = issue?.origin; - switch (issue?.code) { - case 'too_small': - return origin === 'number' || origin === 'bigint' || origin === 'date' ? 'min_value' - : origin === 'array' || origin === 'set' ? 'min_items' - : 'min_length'; - case 'too_big': - return origin === 'number' || origin === 'bigint' || origin === 'date' ? 'max_value' - : origin === 'array' || origin === 'set' ? 'max_items' - : 'max_length'; - case 'invalid_format': - return issue?.format === 'email' ? 'invalid_email' - : issue?.format === 'url' ? 'invalid_url' - : 'invalid_format'; - case 'invalid_type': { - // Zod spells "absent" as a type mismatch against `undefined`, so a - // MISSING required property arrives here rather than as its own code. - // The issue itself cannot tell the two apart — v4 carries `expected` - // and a message but not the offending value — so the only honest - // discriminator is the parsed input, walked to `path`. Without it we - // keep `invalid_type`: reading "received undefined" out of the message - // would make the wire contract depend on Zod's phrasing, which is the - // leak this mapping exists to stop. - // - // `path` is passed in rather than read off the issue because a union - // BRANCH issue carries a path relative to the union (#5014): walking - // the relative one would read the wrong slot of the input — usually - // `undefined` — and report every branch mismatch as `required`. - if (!inputProvided) return 'invalid_type'; - return valueAtPath(input, path) === undefined ? 'required' : 'invalid_type'; - } - case 'invalid_value': - // A closed set (`z.enum`, `z.literal`) the value is not a member of. - return 'invalid_option'; - case 'unrecognized_keys': - return 'unknown_field'; - case 'invalid_union': - case 'invalid_element': - case 'invalid_key': - return 'invalid_shape'; - case 'not_multiple_of': - case 'custom': - default: - // A catalog member, not a leak: an unmapped Zod code still lands on a - // code the client can read, and `message` carries the specifics. - return 'invalid_value'; - } -} - -/** Walk a Zod issue `path` into the value that was parsed. */ -function valueAtPath(input: unknown, path: unknown): unknown { - if (!Array.isArray(path)) return undefined; - let cur: any = input; - for (const seg of path) { - if (cur === null || cur === undefined) return undefined; - cur = cur[seg as any]; - } - return cur; -} - -/** - * How many levels of nested issues are expanded below a top-level issue, and - * how many equally-informative union branches are emitted at one level. - * - * Both bounds — and the whole selection policy below — are the ones - * `formatZodError` landed for the CLI/spec side of this defect (#4971, - * `spec/src/shared/error-map.zod.ts`). They are duplicated rather than imported - * because spec exports only the STRING renderer (`formatZodIssue`), and the wire - * needs structured `{field, code, message}` entries; the *verdict* must match all - * the same, or one mistake gets two different prescriptions depending on whether - * the author published from the terminal or POSTed to the API (#5014). - */ -const NESTED_EXPANSION_DEPTH_LIMIT = 3; -const UNION_BRANCH_EMIT_LIMIT = 3; - -/** - * [#5389] The issue codes that hang their real diagnosis on `issue.issues` - * rather than on `invalid_union`'s `issue.errors`. - * - * `invalid_key` is raised when `z.record(K, V)`'s KEY schema rejects a key (and - * by `z.map` for a non-`PropertyKey` key); `invalid_element` when `z.map`'s - * VALUE schema rejects the value under such a key. Both carry a bare wrapper - * message ("Invalid key in record") with everything the client needs one level - * down — the same defect as #5014, one property name over. Kept in step with - * `CONTAINER_ISSUE_CODES` in `spec/src/shared/error-map.zod.ts`. - */ -const CONTAINER_ISSUE_CODES: ReadonlySet = new Set(['invalid_key', 'invalid_element']); - -/** A Zod issue path, normalised to the array Zod always produces. */ -function issuePathOf(issue: any): Array { - return Array.isArray(issue?.path) ? issue.path : []; -} - -/** - * True when a branch only complains that the value is the wrong *kind* at the - * branch root — `expected string, received object` for the string member of - * `z.union([z.string(), SomeObject])`. - * - * Such a branch carries no prescription: the author never intended it, and - * emitting it is the "N branches, N times the noise" failure. An empty branch - * (zod's "matched multiple" variant carries `errors: []`) counts as - * uninformative too — `every` on an empty list is `true`. - */ -function isKindMismatchOnly(issues: readonly any[]): boolean { - return issues.every( - (issue) => - issuePathOf(issue).length === 0 - && (issue?.code === 'invalid_type' || issue?.code === 'invalid_value'), - ); -} - -/** True when a branch carries the #4001 campaign's unknown-key prescription. */ -function carriesUnknownKey(issues: readonly any[]): boolean { - return issues.some((issue) => issue?.code === 'unrecognized_keys'); -} - -/** - * Pick the branch(es) of a failed union whose issues actually explain the - * failure. Ranking, in order (identical to `selectUnionBranches` in - * `spec/src/shared/error-map.zod.ts`): - * - * 1. **Kind-mismatch-only branches are dropped entirely.** If *every* branch is - * one — a plain `z.union([z.string(), z.number()])` handed an object — - * nothing is selected and the union reports exactly what it always has. - * 2. **Fewest issues wins.** The branch the author was closest to hitting - * complains least, so "fewest" is what keeps ONE unknown key from arriving as - * N `fields[]` entries, one per branch. - * 3. **A branch carrying `unrecognized_keys` breaks a tie**, because that is - * where the curated prose lives. - * 4. Declaration order breaks what remains, so the wire is deterministic. - * - * Branches that tie at the top are all emitted (capped): when two shapes explain - * the failure equally well, privileging the first by accident of declaration - * order would be a lie about which shape was expected. - */ -function selectUnionBranches(branches: readonly (readonly any[])[]): readonly (readonly any[])[] { - const informative = branches - .map((issues, index) => ({ issues, index })) - .filter((branch) => !isKindMismatchOnly(branch.issues)); - if (informative.length === 0) return []; - - const rank = (branch: { issues: readonly any[] }): [number, number] => [ - branch.issues.length, - carriesUnknownKey(branch.issues) ? 0 : 1, - ]; - - const sorted = [...informative].sort((a, b) => { - const [aCount, aKeys] = rank(a); - const [bCount, bKeys] = rank(b); - return aCount - bCount || aKeys - bKeys || a.index - b.index; - }); - - const [bestCount, bestKeys] = rank(sorted[0]!); - return sorted - .filter((branch) => { - const [count, keys] = rank(branch); - return count === bestCount && keys === bestKeys; - }) - .slice(0, UNION_BRANCH_EMIT_LIMIT) - .map((branch) => branch.issues); -} - -/** - * One issue → its `fields[]` entries, appended to `out`. - * - * An ordinary issue is one entry. An `invalid_union` is its own entry (zod's - * bare `"Invalid input"`, mapped to `invalid_shape`) FOLLOWED by the entries of - * the branches that explain it, with `field` resolved against the union's own - * path — branch paths are relative to it. - * - * [#5389] An `invalid_key` / `invalid_element` behaves the same way one property - * name over: its own entry (zod's `"Invalid key in record"`, also - * `invalid_shape`) followed by the entries on `issue.issues`, whose paths are - * likewise relative. The one difference from a union: those issues are not - * competing candidates, so they are NOT ranked or capped — every one of them is - * a true statement about the value, and dropping any would be dropping a real - * diagnosis rather than declining to guess. - * - * The union's entry is kept rather than replaced: it is the only entry naming - * the slot the client sent, existing clients already read it, and when every - * branch is uninformative it is still the whole answer. So the expansion is - * strictly ADDITIVE — no entry that shipped before this changed is gone or - * renumbered, only newly accompanied (ADR-0114: same `{field, code, message}` - * shape as {@link mapDataError}, which has never bounded the array's length). - * - * `seen` de-duplicates entries *within one top-level issue*: two branches that - * reject the same key with the same words say it once. Union entries themselves - * are exempt, since two same-path `"Invalid input"` entries can head genuinely - * different sub-trees. - * - * Deliberate divergence from the spec-side renderer: where it prints a trailing - * "… and N more branches rejected this value", this emits nothing. That line is - * a rendering affordance; a `fields[]` entry must name a real field and carry a - * catalog code, and the omission note has neither. + * Re-exported unchanged: this module's routes call it exactly as before, and + * `zod-field-codes.test.ts` / `zod-union-fields.test.ts` keep pinning the + * shared implementation to the wire contract this transport always had. */ -function collectIssueFields( - issue: any, - parentPath: Array, - depth: number, - seen: Set, - input: unknown, - inputProvided: boolean, - out: Array<{ field: string; code: FieldErrorCode; message: string }>, -): void { - const ownPathIsArray = Array.isArray(issue?.path); - const path = ownPathIsArray ? [...parentPath, ...issue.path] : parentPath; - const field = ownPathIsArray - ? path.join('.') - : [...parentPath, String(issue?.path ?? '')].join('.'); - - const branches: readonly (readonly any[])[] = issue?.code === 'invalid_union' && Array.isArray(issue?.errors) - ? issue.errors.filter((branch: unknown): branch is any[] => Array.isArray(branch)) - : []; - const contained: readonly any[] = CONTAINER_ISSUE_CODES.has(issue?.code) && Array.isArray(issue?.issues) - ? issue.issues - : []; - const expandable = (branches.length > 0 || contained.length > 0) - && depth < NESTED_EXPANSION_DEPTH_LIMIT; - - const entry = { - field, - // A non-array path keeps the pre-#5014 reading (`valueAtPath` bails and - // the mapper stays conservative) instead of being coerced into one. - code: zodIssueToFieldCode(issue, ownPathIsArray ? path : issue?.path, input, inputProvided), - message: String(issue?.message ?? 'Invalid value'), - }; - - if (!expandable) { - const key = JSON.stringify([entry.field, entry.code, entry.message]); - if (seen.has(key)) return; - seen.add(key); - } - out.push(entry); - if (!expandable) return; - - if (branches.length > 0) { - for (const branch of selectUnionBranches(branches)) { - for (const nested of branch) { - collectIssueFields(nested, path, depth + 1, seen, input, inputProvided, out); - } - } - return; - } - - for (const nested of contained) { - collectIssueFields(nested, path, depth + 1, seen, input, inputProvided, out); - } -} - -/** - * Zod issues → the data surface's `fields[]` validation envelope - * (`{ field, code, message }`, docs/api/wire-format §7). - * - * A schema `.parse()` at a route ingress must report failures in the SAME shape - * a validator-thrown `VALIDATION_FAILED` does through {@link mapDataError} - * (#3918) — otherwise a client keying on `fields` has to learn a second shape - * per route, and `code: 'VALIDATION_FAILED'` stops meaning one thing on the - * wire. Since ADR-0114 that sameness covers the `code` VALUE too, not just the - * shape: see {@link zodIssueToFieldCode}. - * - * A rejection behind a `z.union` is expanded (#5014): zod folds every branch of - * a failed union into ONE top-level issue whose message is the literal - * `"Invalid input"`, so mapping only top-level issues put `{field: 'query.search', - * code: 'invalid_shape', message: 'Invalid input'}` on the wire while the branch - * that says WHICH key is wrong — required-property and unknown-key prescriptions - * alike — was produced and dropped. See {@link collectIssueFields}. - */ -export function zodIssuesToFields( - issues: unknown, - ...input: [] | [unknown] -): Array<{ field: string; code: FieldErrorCode; message: string }> { - if (!Array.isArray(issues)) return []; - const inputProvided = input.length > 0; - const out: Array<{ field: string; code: FieldErrorCode; message: string }> = []; - for (const issue of issues) { - // A fresh `seen` per top-level issue: de-duplication is about one - // union's branches agreeing, never about two independent issues. - collectIssueFields(issue, [], 0, new Set(), input[0], inputProvided, out); - } - return out; -} +import { zodIssuesToFields } from '@objectstack/spec/api'; +export { zodIssuesToFields }; /** * How many characters of a domain error's OWN message reach the client. diff --git a/packages/runtime/src/domains/analytics.ts b/packages/runtime/src/domains/analytics.ts index 4f7c515607..58d30c6351 100644 --- a/packages/runtime/src/domains/analytics.ts +++ b/packages/runtime/src/domains/analytics.ts @@ -52,7 +52,10 @@ function assertAnalyticsQueryBody(body: unknown): void { if ('filters' in b && !('where' in b)) { throw validationFailure( '`filters` is not an AnalyticsQuery field — use `where` (canonical Query DSL FilterCondition, the same shape find() takes).', - [{ field: 'filters', code: 'unrecognized_keys', message: 'use `where` instead of `filters`' }], + // `unknown_field` — the ADR-0114 catalog member for "a key the + // target does not declare"; this entry used to hand-spell Zod's + // `unrecognized_keys`, a code outside the closed catalog (#8124). + [{ field: 'filters', code: 'unknown_field', message: 'use `where` instead of `filters`' }], ); } } diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index ddba59f2ef..5fb7b47fba 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -420,11 +420,11 @@ async function refuseUnrelatedScreenRead( * `invalid_value`, the ADR-0114 catalog's "rejected for a reason no other * member names". * - * ⚠️ The Zod branch's per-issue `code` is whatever {@link fieldsFromZodIssues} - * produces, which today is Zod's own vocabulary rather than the ADR-0114 D3 - * catalog. That pass-through is this package's, not this route's — `/analytics` - * and `/notifications` emit through the same helper — so it is filed as #8124 - * rather than forked here into a third dialect. + * The Zod branch's per-issue `code` is whatever {@link fieldsFromZodIssues} + * produces — since #8124 that is the ADR-0114 D3 catalog: the helper maps + * through `zodIssuesToFields` (`@objectstack/spec`), the same table the REST + * transport applies, so `/analytics`, `/notifications` and this route speak + * one field-code vocabulary instead of leaking Zod's. */ function flowDefinitionRefusal(err: any): unknown { // The producer declared its class; the boundary does not overrule it. @@ -767,7 +767,11 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str if (unknownKeys.length > 0) { throw validationFailure( `Unknown key${unknownKeys.length > 1 ? 's' : ''} ${unknownKeys.map((k) => `\`${k}\``).join(', ')} — the toggle body is { enabled?: boolean }`, - unknownKeys.map((k) => ({ field: k, code: 'unrecognized_keys', message: 'not a toggle field — did you mean `enabled`?' })), + // `unknown_field` — the ADR-0114 catalog member for "a + // key the target does not declare"; this entry used to + // hand-spell Zod's `unrecognized_keys`, a code outside + // the closed catalog (#8124). + unknownKeys.map((k) => ({ field: k, code: 'unknown_field', message: 'not a toggle field — did you mean `enabled`?' })), ); } if ('enabled' in toggleBody && typeof (toggleBody as Record).enabled !== 'boolean') { diff --git a/packages/spec/src/api/index.ts b/packages/spec/src/api/index.ts index 9751996cd2..49c1374cee 100644 --- a/packages/spec/src/api/index.ts +++ b/packages/spec/src/api/index.ts @@ -40,6 +40,10 @@ export * from './odata.zod'; export * from './batch.zod'; export * from './http-cache.zod'; export * from './errors.zod'; +// The ADR-0114 D3 boundary mapper — the ONE implementation of Zod issue code → +// `FieldErrorCode` in the repo (#8124); `@objectstack/rest` and +// `@objectstack/types` both consume it. +export { zodIssuesToFields } from './zod-issues-to-fields'; export * from './error-code-ledger.zod'; export * from './protocol.zod'; export * from './rest-server.zod'; diff --git a/packages/spec/src/api/zod-issues-to-fields.test.ts b/packages/spec/src/api/zod-issues-to-fields.test.ts new file mode 100644 index 0000000000..c6db86cb44 --- /dev/null +++ b/packages/spec/src/api/zod-issues-to-fields.test.ts @@ -0,0 +1,129 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #8124 — `zodIssuesToFields` in its new home: the ADR-0114 D3 mapper lives in + * this package, beside the `FieldErrorCode` catalog it is total over, so + * `@objectstack/rest` AND `@objectstack/types` read one implementation of the + * table. + * + * The transport-grade behavior pins (every D3 row, union expansion, container + * descent, junk tolerance) live in `packages/rest/src/zod-field-codes.test.ts` + * and `zod-union-fields.test.ts`, which import through rest's re-export — kept + * there deliberately, so the move is proven behavior-identical by the tests + * that pinned the old module-local copy. What THIS file owns is the catalog + * totality claim from the spec side, driven per ADR-0114 D3's own discipline: + * REAL `safeParse` calls against the real `FlowSchema` (the #8055 fixture + * source — the parse whose leaked codes #8124 was filed about), never + * hand-written issue objects. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { FieldErrorCode } from './errors.zod'; +import { zodIssuesToFields } from './zod-issues-to-fields'; +import { FlowSchema } from '../automation/flow.zod'; + +/** Parse and map, asserting the fixture really fails — with the parsed input. */ +const fieldsFor = (schema: { safeParse: (v: unknown) => any }, value: unknown) => { + const r = schema.safeParse(value); + expect(r.success, 'the fixture must actually fail to parse').toBe(false); + return zodIssuesToFields(r.error.issues, value); +}; + +/** The same, without the input — the degraded path a caller without it takes. */ +const fieldsForBlind = (schema: { safeParse: (v: unknown) => any }, value: unknown) => { + const r = schema.safeParse(value); + expect(r.success, 'the fixture must actually fail to parse').toBe(false); + return zodIssuesToFields(r.error.issues); +}; + +/** A flow definition that parses clean — each fixture below is one edit away. */ +const WELL_FORMED_FLOW = { + name: 'welcome_flow', + label: 'Welcome', + type: 'autolaunched', + nodes: [{ id: 'n', type: 'notify', label: 'Notify', config: { message: 'hi' } }], + edges: [], +}; + +describe('zodIssuesToFields — the D3 table against the real FlowSchema (#8124/#8055)', () => { + it('the well-formed control parses clean, so every failure below is the planted edit', () => { + expect(FlowSchema.safeParse(WELL_FORMED_FLOW).success).toBe(true); + }); + + it('an unknown node key arrives as unknown_field, never unrecognized_keys', () => { + const fields = fieldsFor(FlowSchema, { + ...WELL_FORMED_FLOW, + nodes: [{ id: 'n', type: 'notify', label: 'Notify', next: 'other' }], + }); + const unknownKey = fields.find((f) => f.message.includes('next')); + expect(unknownKey, 'the offending key must still be named').toBeDefined(); + expect(unknownKey!.code).toBe('unknown_field'); + expect(fields.map((f) => f.code)).not.toContain('unrecognized_keys'); + }); + + it('a node missing `label` is required with the input, invalid_type without — never a leak', () => { + const bad = { ...WELL_FORMED_FLOW, nodes: [{ id: 'n', type: 'notify', config: { message: 'hi' } }] }; + + const withInput = fieldsFor(FlowSchema, bad); + const labelEntry = withInput.find((f) => f.field.endsWith('label')); + expect(labelEntry).toBeDefined(); + expect(labelEntry!.code).toBe('required'); + + const blind = fieldsForBlind(FlowSchema, bad); + const blindLabel = blind.find((f) => f.field.endsWith('label')); + expect(blindLabel).toBeDefined(); + expect(blindLabel!.code).toBe('invalid_type'); + }); + + it('every code emitted for every #8055-shaped fixture is a catalog member', () => { + const fixtures: unknown[] = [ + { ...WELL_FORMED_FLOW, nodes: [{ id: 'n', type: 'notify', config: { message: 'hi' } }] }, + { ...WELL_FORMED_FLOW, nodes: [{ id: 'n', type: 'notify', label: 'Notify', next: 'other' }] }, + { ...WELL_FORMED_FLOW, name: undefined }, + { ...WELL_FORMED_FLOW, type: 'no_such_flow_type' }, + { ...WELL_FORMED_FLOW, nodes: 'not-an-array' }, + 'not even an object', + ]; + for (const fixture of fixtures) { + const fields = fieldsFor(FlowSchema, fixture); + expect(fields.length).toBeGreaterThan(0); + for (const f of fields) { + expect( + () => FieldErrorCode.parse(f.code), + `'${f.code}' leaked onto the wire for ${JSON.stringify(fixture).slice(0, 60)}`, + ).not.toThrow(); + } + } + }); +}); + +describe('zodIssuesToFields — the ambiguous and unmapped Zod codes land on catalog members', () => { + it('too_small / too_big are split by origin, the D3 disambiguation', () => { + expect(fieldsFor(z.object({ a: z.string().min(3) }), { a: 'x' })[0].code).toBe('min_length'); + expect(fieldsFor(z.object({ a: z.number().min(5) }), { a: 1 })[0].code).toBe('min_value'); + expect(fieldsFor(z.object({ a: z.array(z.string()).min(2) }), { a: ['one'] })[0].code).toBe('min_items'); + expect(fieldsFor(z.object({ a: z.string().max(2) }), { a: 'toolong' })[0].code).toBe('max_length'); + expect(fieldsFor(z.object({ a: z.number().max(1) }), { a: 9 })[0].code).toBe('max_value'); + expect(fieldsFor(z.object({ a: z.array(z.string()).max(1) }), { a: ['a', 'b'] })[0].code).toBe('max_items'); + }); + + it('custom lands on invalid_value; invalid_union on invalid_shape — catalog members both', () => { + expect(fieldsFor(z.object({ a: z.string().refine(() => false, 'no') }), { a: 'x' })[0].code) + .toBe('invalid_value'); + const unionFields = fieldsFor( + z.object({ u: z.union([z.object({ k: z.string() }), z.object({ j: z.number() })]) }), + { u: { k: 42 } }, + ); + expect(unionFields[0].code).toBe('invalid_shape'); + for (const f of unionFields) { + expect(() => FieldErrorCode.parse(f.code), `'${f.code}' is not a catalog member`).not.toThrow(); + } + }); + + it('tolerates a non-array argument', () => { + for (const junk of [null, undefined, {}, 'issues', 0]) { + expect(zodIssuesToFields(junk)).toEqual([]); + } + }); +}); diff --git a/packages/spec/src/api/zod-issues-to-fields.ts b/packages/spec/src/api/zod-issues-to-fields.ts new file mode 100644 index 0000000000..c2b798a7ad --- /dev/null +++ b/packages/spec/src/api/zod-issues-to-fields.ts @@ -0,0 +1,342 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Zod issues → ADR-0114 `fields[]` entries — THE D3 boundary mapper. + * + * ADR-0114 D2 makes `FieldErrorSchema.code` a closed catalog ({@link FieldErrorCode}, + * `./errors.zod.ts`); D3 says Zod is mapped at the boundary, never passed + * through, with an unmapped Zod code landing on `invalid_value` (a catalog + * member) rather than leaking. This module IS that mapping, and it is meant to + * be the only implementation of D3's table in the repo (#8124): a second copy + * is exactly the drift the ADR exists to prevent — the two would disagree the + * first time Zod adds an issue code. + * + * ## Why it lives in `@objectstack/spec` (#8124) + * + * It grew up module-local in `@objectstack/rest`'s `rest-server.ts`, while + * `@objectstack/types`' `fieldsFromZodIssues` — the helper the runtime domain + * routes emit through — still passed `issue.code` through verbatim, putting + * `unrecognized_keys` / `too_small` on a wire position the spec declares as + * `FieldErrorCode`. `types` cannot import `rest` (runtime → rest → types, the + * arrow only points one way), so sharing the compliant copy meant moving it to + * the one package both depend on — which is also the contract-first home: next + * to the catalog it is total over. `rest` re-exports it unchanged; `types` + * maps through it. + * + * ## Relation to `shared/error-map.zod.ts` + * + * The union-branch selection policy below (limits, ranking, container descent) + * is the one `formatZodIssue`'s STRING renderer applies (#4971/#5389, one + * directory over in `shared/error-map.zod.ts`). The two walks stay separate + * implementations on purpose — one renders indented prose lines, this one + * produces structured `{field, code, message}` entries — but the *verdict* + * (which branches explain a failure) must match, or one mistake gets two + * different prescriptions depending on whether the author published from the + * terminal or POSTed to the API (#5014). Change the policy in either file and + * the sibling moves in the same PR. + */ + +import type { FieldErrorCode } from './errors.zod'; + +/** + * A Zod issue → the field-level catalog (ADR-0114 D3). + * + * Zod's issue codes are Zod's API, not ours, and the wire used to pass them + * straight through. Two things were wrong with that. The wire carried two + * vocabularies on one position — `too_small` from a route that parses with + * Zod, `min_length` from the validators — so a client could not read a field + * code without knowing which route served it. And Zod's own codes are + * ambiguous alone: `too_small` covers a short string, a small number AND a + * short array. + * + * `origin` and `format` disambiguate every case, so the mapping is total + * rather than best-effort. The one row that fixes a user-visible bug rather + * than tidying a name: Zod reports a MISSING required property as + * `invalid_type` (expected string, received undefined), so passing it through + * marked a missing input as a type error. + */ +function zodIssueToFieldCode(issue: any, path: unknown, input?: unknown, inputProvided = false): FieldErrorCode { + const origin = issue?.origin; + switch (issue?.code) { + case 'too_small': + return origin === 'number' || origin === 'bigint' || origin === 'date' ? 'min_value' + : origin === 'array' || origin === 'set' ? 'min_items' + : 'min_length'; + case 'too_big': + return origin === 'number' || origin === 'bigint' || origin === 'date' ? 'max_value' + : origin === 'array' || origin === 'set' ? 'max_items' + : 'max_length'; + case 'invalid_format': + return issue?.format === 'email' ? 'invalid_email' + : issue?.format === 'url' ? 'invalid_url' + : 'invalid_format'; + case 'invalid_type': { + // Zod spells "absent" as a type mismatch against `undefined`, so a + // MISSING required property arrives here rather than as its own code. + // The issue itself cannot tell the two apart — v4 carries `expected` + // and a message but not the offending value — so the only honest + // discriminator is the parsed input, walked to `path`. Without it we + // keep `invalid_type`: reading "received undefined" out of the message + // would make the wire contract depend on Zod's phrasing, which is the + // leak this mapping exists to stop. + // + // `path` is passed in rather than read off the issue because a union + // BRANCH issue carries a path relative to the union (#5014): walking + // the relative one would read the wrong slot of the input — usually + // `undefined` — and report every branch mismatch as `required`. + if (!inputProvided) return 'invalid_type'; + return valueAtPath(input, path) === undefined ? 'required' : 'invalid_type'; + } + case 'invalid_value': + // A closed set (`z.enum`, `z.literal`) the value is not a member of. + return 'invalid_option'; + case 'unrecognized_keys': + return 'unknown_field'; + case 'invalid_union': + case 'invalid_element': + case 'invalid_key': + return 'invalid_shape'; + case 'not_multiple_of': + case 'custom': + default: + // A catalog member, not a leak: an unmapped Zod code still lands on a + // code the client can read, and `message` carries the specifics. + return 'invalid_value'; + } +} + +/** Walk a Zod issue `path` into the value that was parsed. */ +function valueAtPath(input: unknown, path: unknown): unknown { + if (!Array.isArray(path)) return undefined; + let cur: any = input; + for (const seg of path) { + if (cur === null || cur === undefined) return undefined; + cur = cur[seg as any]; + } + return cur; +} + +/** + * How many levels of nested issues are expanded below a top-level issue, and + * how many equally-informative union branches are emitted at one level. + * + * Both bounds — and the whole selection policy below — are the ones + * `formatZodError` landed for the CLI/spec side of this defect (#4971, + * `shared/error-map.zod.ts`). See the module header for why the two walks are + * siblings that must agree rather than one shared implementation. + */ +const NESTED_EXPANSION_DEPTH_LIMIT = 3; +const UNION_BRANCH_EMIT_LIMIT = 3; + +/** + * [#5389] The issue codes that hang their real diagnosis on `issue.issues` + * rather than on `invalid_union`'s `issue.errors`. + * + * `invalid_key` is raised when `z.record(K, V)`'s KEY schema rejects a key (and + * by `z.map` for a non-`PropertyKey` key); `invalid_element` when `z.map`'s + * VALUE schema rejects the value under such a key. Both carry a bare wrapper + * message ("Invalid key in record") with everything the client needs one level + * down — the same defect as #5014, one property name over. Kept in step with + * `CONTAINER_ISSUE_CODES` in `shared/error-map.zod.ts`. + */ +const CONTAINER_ISSUE_CODES: ReadonlySet = new Set(['invalid_key', 'invalid_element']); + +/** A Zod issue path, normalised to the array Zod always produces. */ +function issuePathOf(issue: any): Array { + return Array.isArray(issue?.path) ? issue.path : []; +} + +/** + * True when a branch only complains that the value is the wrong *kind* at the + * branch root — `expected string, received object` for the string member of + * `z.union([z.string(), SomeObject])`. + * + * Such a branch carries no prescription: the author never intended it, and + * emitting it is the "N branches, N times the noise" failure. An empty branch + * (zod's "matched multiple" variant carries `errors: []`) counts as + * uninformative too — `every` on an empty list is `true`. + */ +function isKindMismatchOnly(issues: readonly any[]): boolean { + return issues.every( + (issue) => + issuePathOf(issue).length === 0 + && (issue?.code === 'invalid_type' || issue?.code === 'invalid_value'), + ); +} + +/** True when a branch carries the #4001 campaign's unknown-key prescription. */ +function carriesUnknownKey(issues: readonly any[]): boolean { + return issues.some((issue) => issue?.code === 'unrecognized_keys'); +} + +/** + * Pick the branch(es) of a failed union whose issues actually explain the + * failure. Ranking, in order (identical to `selectUnionBranches` in + * `shared/error-map.zod.ts`): + * + * 1. **Kind-mismatch-only branches are dropped entirely.** If *every* branch is + * one — a plain `z.union([z.string(), z.number()])` handed an object — + * nothing is selected and the union reports exactly what it always has. + * 2. **Fewest issues wins.** The branch the author was closest to hitting + * complains least, so "fewest" is what keeps ONE unknown key from arriving as + * N `fields[]` entries, one per branch. + * 3. **A branch carrying `unrecognized_keys` breaks a tie**, because that is + * where the curated prose lives. + * 4. Declaration order breaks what remains, so the wire is deterministic. + * + * Branches that tie at the top are all emitted (capped): when two shapes explain + * the failure equally well, privileging the first by accident of declaration + * order would be a lie about which shape was expected. + */ +function selectUnionBranches(branches: readonly (readonly any[])[]): readonly (readonly any[])[] { + const informative = branches + .map((issues, index) => ({ issues, index })) + .filter((branch) => !isKindMismatchOnly(branch.issues)); + if (informative.length === 0) return []; + + const rank = (branch: { issues: readonly any[] }): [number, number] => [ + branch.issues.length, + carriesUnknownKey(branch.issues) ? 0 : 1, + ]; + + const sorted = [...informative].sort((a, b) => { + const [aCount, aKeys] = rank(a); + const [bCount, bKeys] = rank(b); + return aCount - bCount || aKeys - bKeys || a.index - b.index; + }); + + const [bestCount, bestKeys] = rank(sorted[0]!); + return sorted + .filter((branch) => { + const [count, keys] = rank(branch); + return count === bestCount && keys === bestKeys; + }) + .slice(0, UNION_BRANCH_EMIT_LIMIT) + .map((branch) => branch.issues); +} + +/** + * One issue → its `fields[]` entries, appended to `out`. + * + * An ordinary issue is one entry. An `invalid_union` is its own entry (zod's + * bare `"Invalid input"`, mapped to `invalid_shape`) FOLLOWED by the entries of + * the branches that explain it, with `field` resolved against the union's own + * path — branch paths are relative to it. + * + * [#5389] An `invalid_key` / `invalid_element` behaves the same way one property + * name over: its own entry (zod's `"Invalid key in record"`, also + * `invalid_shape`) followed by the entries on `issue.issues`, whose paths are + * likewise relative. The one difference from a union: those issues are not + * competing candidates, so they are NOT ranked or capped — every one of them is + * a true statement about the value, and dropping any would be dropping a real + * diagnosis rather than declining to guess. + * + * The union's entry is kept rather than replaced: it is the only entry naming + * the slot the client sent, existing clients already read it, and when every + * branch is uninformative it is still the whole answer. So the expansion is + * strictly ADDITIVE — no entry that shipped before this changed is gone or + * renumbered, only newly accompanied (ADR-0114: same `{field, code, message}` + * shape as rest's `mapDataError`, which has never bounded the array's length). + * + * `seen` de-duplicates entries *within one top-level issue*: two branches that + * reject the same key with the same words say it once. Union entries themselves + * are exempt, since two same-path `"Invalid input"` entries can head genuinely + * different sub-trees. + * + * Deliberate divergence from the spec-side renderer: where it prints a trailing + * "… and N more branches rejected this value", this emits nothing. That line is + * a rendering affordance; a `fields[]` entry must name a real field and carry a + * catalog code, and the omission note has neither. + */ +function collectIssueFields( + issue: any, + parentPath: Array, + depth: number, + seen: Set, + input: unknown, + inputProvided: boolean, + out: Array<{ field: string; code: FieldErrorCode; message: string }>, +): void { + const ownPathIsArray = Array.isArray(issue?.path); + const path = ownPathIsArray ? [...parentPath, ...issue.path] : parentPath; + const field = ownPathIsArray + ? path.join('.') + : [...parentPath, String(issue?.path ?? '')].join('.'); + + const branches: readonly (readonly any[])[] = issue?.code === 'invalid_union' && Array.isArray(issue?.errors) + ? issue.errors.filter((branch: unknown): branch is any[] => Array.isArray(branch)) + : []; + const contained: readonly any[] = CONTAINER_ISSUE_CODES.has(issue?.code) && Array.isArray(issue?.issues) + ? issue.issues + : []; + const expandable = (branches.length > 0 || contained.length > 0) + && depth < NESTED_EXPANSION_DEPTH_LIMIT; + + const entry = { + field, + // A non-array path keeps the pre-#5014 reading (`valueAtPath` bails and + // the mapper stays conservative) instead of being coerced into one. + code: zodIssueToFieldCode(issue, ownPathIsArray ? path : issue?.path, input, inputProvided), + message: String(issue?.message ?? 'Invalid value'), + }; + + if (!expandable) { + const key = JSON.stringify([entry.field, entry.code, entry.message]); + if (seen.has(key)) return; + seen.add(key); + } + out.push(entry); + if (!expandable) return; + + if (branches.length > 0) { + for (const branch of selectUnionBranches(branches)) { + for (const nested of branch) { + collectIssueFields(nested, path, depth + 1, seen, input, inputProvided, out); + } + } + return; + } + + for (const nested of contained) { + collectIssueFields(nested, path, depth + 1, seen, input, inputProvided, out); + } +} + +/** + * Zod issues → the data surface's `fields[]` validation envelope + * (`{ field, code, message }`, docs/api/wire-format §7). + * + * A schema `.parse()` at a route ingress must report failures in the SAME shape + * a validator-thrown `VALIDATION_FAILED` does through rest's `mapDataError` + * (#3918) — otherwise a client keying on `fields` has to learn a second shape + * per route, and `code: 'VALIDATION_FAILED'` stops meaning one thing on the + * wire. Since ADR-0114 that sameness covers the `code` VALUE too, not just the + * shape: see {@link zodIssueToFieldCode}. + * + * The optional second argument is the PARSED INPUT, and it buys precision, not + * admission: with it, a missing required property is reported as `required` + * rather than the `invalid_type` Zod spells it as. A caller that does not have + * the input at hand degrades per the D3 table — every code is still a catalog + * member — rather than leaking. + * + * A rejection behind a `z.union` is expanded (#5014): zod folds every branch of + * a failed union into ONE top-level issue whose message is the literal + * `"Invalid input"`, so mapping only top-level issues put `{field: 'query.search', + * code: 'invalid_shape', message: 'Invalid input'}` on the wire while the branch + * that says WHICH key is wrong — required-property and unknown-key prescriptions + * alike — was produced and dropped. See {@link collectIssueFields}. + */ +export function zodIssuesToFields( + issues: unknown, + ...input: [] | [unknown] +): Array<{ field: string; code: FieldErrorCode; message: string }> { + if (!Array.isArray(issues)) return []; + const inputProvided = input.length > 0; + const out: Array<{ field: string; code: FieldErrorCode; message: string }> = []; + for (const issue of issues) { + // A fresh `seen` per top-level issue: de-duplication is about one + // union's branches agreeing, never about two independent issues. + collectIssueFields(issue, [], 0, new Set(), input[0], inputProvided, out); + } + return out; +} diff --git a/packages/types/src/validation-failure.test.ts b/packages/types/src/validation-failure.test.ts new file mode 100644 index 0000000000..1d047daf49 --- /dev/null +++ b/packages/types/src/validation-failure.test.ts @@ -0,0 +1,97 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #8124 — `fieldsFromZodIssues` no longer leaks Zod's issue codes onto the + * wire's `fields[].code`. + * + * The runtime domain routes (`/analytics`, `/notifications`, `/automation`) + * emit their entry refusals through this helper, and it used to assign + * `issue.code` verbatim — `unrecognized_keys`, `too_small`, … on a position + * `FieldErrorSchema.code` declares as the CLOSED ADR-0114 catalog. It now maps + * through `zodIssuesToFields` (`@objectstack/spec`, the one D3 implementation). + * + * Per ADR-0114 D3's own discipline these tests drive REAL `safeParse` calls — + * against the very spec schemas the runtime domains parse with (this package + * declares no `zod` of its own, and a hand-written issue fixture is exactly + * what D3 says not to trust) — never hand-built issue objects. + */ + +import { describe, it, expect } from 'vitest'; +import { FieldErrorCode, MarkNotificationsReadRequestSchema } from '@objectstack/spec/api'; +import { FlowSchema } from '@objectstack/spec/automation'; +import { fieldsFromZodIssues } from './validation-failure'; + +/** Parse `value` against `schema`, asserting it fails, and map the issues. */ +function issuesOf(schema: { safeParse: (v: unknown) => any }, value: unknown) { + const r = schema.safeParse(value); + expect(r.success, 'the fixture must actually fail to parse').toBe(false); + return r.error.issues; +} + +/** A flow definition that parses clean — fixtures below are one edit away. */ +const WELL_FORMED_FLOW = { + name: 'welcome_flow', + label: 'Welcome', + type: 'autolaunched', + nodes: [{ id: 'n', type: 'notify', label: 'Notify', config: { message: 'hi' } }], + edges: [], +}; + +describe('fieldsFromZodIssues — ADR-0114 D3 catalog codes, not Zod codes (#8124)', () => { + it('an unknown node key on the real FlowSchema is unknown_field, not unrecognized_keys', () => { + // The exact fixture #8124 measured: the #8055 flow-registration refusal. + const fields = fieldsFromZodIssues(issuesOf(FlowSchema, { + ...WELL_FORMED_FLOW, + nodes: [{ id: 'n', type: 'notify', label: 'Notify', next: 'other' }], + })); + expect(fields.length).toBeGreaterThan(0); + expect(fields.map((f) => f.code)).toContain('unknown_field'); + expect(fields.map((f) => f.code)).not.toContain('unrecognized_keys'); + // The located fault survives the mapping. + expect(fields.some((f) => f.message.includes('next'))).toBe(true); + }); + + it('every emitted code is a catalog member, for every fixture', () => { + const fixtures: Array<[{ safeParse: (v: unknown) => any }, unknown]> = [ + // The mark-read contract the notifications domain parses (#3899). + [MarkNotificationsReadRequestSchema, { notificationIds: ['n1'] }], + [MarkNotificationsReadRequestSchema, { ids: 'n1' }], + [MarkNotificationsReadRequestSchema, []], + // The flow contract the automation domain parses (#8055). + [FlowSchema, { ...WELL_FORMED_FLOW, nodes: [{ id: 'n', type: 'notify', config: {} }] }], + [FlowSchema, 'not even an object'], + ]; + for (const [schema, value] of fixtures) { + for (const f of fieldsFromZodIssues(issuesOf(schema, value))) { + expect( + () => FieldErrorCode.parse(f.code), + `'${f.code}' leaked for ${JSON.stringify(value)?.slice(0, 60)}`, + ).not.toThrow(); + } + } + }); + + it("a root-level failure keeps the '(body)' spelling the dispatcher documents", () => { + // A body that is the wrong TYPE entirely has no path to point at; the + // domains' contrast tests and `flowDefinitionRefusal` both read this + // convention, so the delegation to `zodIssuesToFields` (which spells an + // empty path as '') must not have changed it. + const fields = fieldsFromZodIssues(issuesOf(FlowSchema, 'not even an object')); + expect(fields.length).toBeGreaterThan(0); + expect(fields[0].field).toBe('(body)'); + }); + + it('the optional input upgrades a missing required property to required', () => { + const bad = { ...WELL_FORMED_FLOW, nodes: [{ id: 'n', type: 'notify', config: { message: 'hi' } }] }; + const issues = issuesOf(FlowSchema, bad); + + // Without the input — every caller today — the D3 degradation: still a + // catalog member, just the less specific one. + const blind = fieldsFromZodIssues(issues); + expect(blind.find((f) => f.field.endsWith('label'))?.code).toBe('invalid_type'); + + // With it, the D3 `invalid_type` split fires. + const informed = fieldsFromZodIssues(issues, bad); + expect(informed.find((f) => f.field.endsWith('label'))?.code).toBe('required'); + }); +}); diff --git a/packages/types/src/validation-failure.ts b/packages/types/src/validation-failure.ts index 0cc1e63e78..4bc03e259e 100644 --- a/packages/types/src/validation-failure.ts +++ b/packages/types/src/validation-failure.ts @@ -37,6 +37,9 @@ * import site still reads the name it always did. */ +import { zodIssuesToFields } from '@objectstack/spec/api'; +import type { FieldErrorCode } from '@objectstack/spec/api'; + /** The HTTP status a validation failure maps to when the error names none. */ export const VALIDATION_FAILED_STATUS = 400; @@ -81,13 +84,34 @@ export function validationFailure(message: string, fields: unknown[]): Error { * Zod issues → the dispatcher's `fields[]` envelope entries * (`{ field, code, message }`). `'(body)'` names a root-level failure — a body * that is the wrong TYPE entirely has no path to point at. + * + * ## The `code` is an ADR-0114 `FieldErrorCode`, not Zod's (#8124) + * + * This used to assign `issue.code` verbatim, which put Zod's own vocabulary + * (`unrecognized_keys`, `too_small`, …) on a wire position + * `FieldErrorSchema.code` declares as a CLOSED catalog — the exact + * pass-through ADR-0114 D3 closed on the REST transport. It now maps through + * `zodIssuesToFields`, the one D3 implementation in the repo, which lives in + * `@objectstack/spec` beside the catalog it is total over (this package cannot + * import `@objectstack/rest`, where the compliant copy grew up — the + * dependency arrow points the other way, which is what #8124 moved it for). + * + * Two things ride along, both additive: + * + * - **The optional `input`** (the value that was parsed) buys the D3 + * `invalid_type` split: with it a MISSING required property is reported as + * `required` instead of the `invalid_type` Zod spells it as. Callers without + * the input at hand degrade per the D3 table — every code is still a + * catalog member. + * - **Union expansion (#5014)**: a rejection behind a `z.union` yields the + * union's own entry PLUS the branch entries that explain it, so entry count + * is not issue count. Read `fields.length` as the number of field errors. */ export function fieldsFromZodIssues( issues: Array<{ path: Array; code: string; message: string }>, -): Array<{ field: string; code: string; message: string }> { - return issues.map((issue) => ({ - field: issue.path.length > 0 ? issue.path.join('.') : '(body)', - code: issue.code, - message: issue.message, - })); + ...input: [] | [unknown] +): Array<{ field: string; code: FieldErrorCode; message: string }> { + return zodIssuesToFields(issues, ...input).map((entry) => + entry.field === '' ? { ...entry, field: '(body)' } : entry, + ); } From e5d34577937da43ff1664ea200c40384725a70b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 05:29:15 +0000 Subject: [PATCH 2/3] chore(spec): regenerate api-surface/export-origins for the new export; add changeset Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Euoy6wyfzgiWtgCg4s6JK2 --- .../adr-0114-d3-mapper-shared-in-spec.md | 34 +++++++++++++++++++ packages/spec/api-surface/api.json | 3 +- packages/spec/export-origins/api.json | 3 +- 3 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 .changeset/adr-0114-d3-mapper-shared-in-spec.md diff --git a/.changeset/adr-0114-d3-mapper-shared-in-spec.md b/.changeset/adr-0114-d3-mapper-shared-in-spec.md new file mode 100644 index 0000000000..a92fdab71d --- /dev/null +++ b/.changeset/adr-0114-d3-mapper-shared-in-spec.md @@ -0,0 +1,34 @@ +--- +"@objectstack/spec": minor +"@objectstack/types": patch +"@objectstack/rest": patch +"@objectstack/runtime": patch +--- + +The ADR-0114 D3 mapper (Zod issue codes → the closed `FieldErrorCode` catalog) is now +`zodIssuesToFields`, exported from `@objectstack/spec` (`@objectstack/spec/api`), and it is +the ONE implementation of D3's table in the repo (#8124). + +Why: `fields[].code` is declared as a closed catalog (`FieldErrorCode`, ADR-0114 D2), but +`@objectstack/types`' `fieldsFromZodIssues` — the helper the runtime `/analytics`, +`/notifications` and `/automation` entry refusals emit through — passed Zod's own issue +codes through verbatim. A refusal carrying `unrecognized_keys` / `too_small` did not parse +against the schema the protocol declares for it, and the same wire slot spoke two +vocabularies depending on which route served it. + +What changed on the wire (all three runtime domain routes): + +- `fields[].code` values are now catalog members: `unrecognized_keys` → `unknown_field`, + `too_small` → `min_length`/`min_value`/`min_items` (by origin), `too_big` → the `max_*` + mirrors, enum misses → `invalid_option`, `custom` and any unmapped Zod code → + `invalid_value`. +- A rejection behind a `z.union` is expanded per #5014: the union's own entry is followed + by the branch entries that explain it, so entry count is no longer issue count. +- Two hand-spelled `unrecognized_keys` literals (the analytics `filters` hint and the + automation toggle unknown-key refusal) now say `unknown_field`, the catalog member. + +`@objectstack/rest` re-exports the shared implementation from `rest-server.ts` and its +behavior is unchanged (its own mapper tests pin that); `fieldsFromZodIssues` keeps its +signature (plus an optional trailing `input` that upgrades a missing required property from +`invalid_type` to `required`, per the D3 table) and keeps the `'(body)'` spelling for +root-level failures. diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 788ec8a9e0..f5004d92cc 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -998,6 +998,7 @@ "readServiceSelfInfo (function)", "resolveDiscoveryEnvironment (function)", "standardErrorCodeForHttpStatus (function)", - "validateApiEndpointDeclarations (function)" + "validateApiEndpointDeclarations (function)", + "zodIssuesToFields (function)" ] } diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 305dd09beb..0245035003 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -998,6 +998,7 @@ "readServiceSelfInfo": "src/api/discovery.zod.ts#readServiceSelfInfo (function)", "resolveDiscoveryEnvironment": "src/api/discovery.zod.ts#resolveDiscoveryEnvironment (function)", "standardErrorCodeForHttpStatus": "src/api/errors.zod.ts#standardErrorCodeForHttpStatus (function)", - "validateApiEndpointDeclarations": "src/api/endpoint-publish-gate.ts#validateApiEndpointDeclarations (function)" + "validateApiEndpointDeclarations": "src/api/endpoint-publish-gate.ts#validateApiEndpointDeclarations (function)", + "zodIssuesToFields": "src/api/zod-issues-to-fields.ts#zodIssuesToFields (function)" } } From 943f7b7e8b3585a957f6cb2da78972b688eaf166 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 06:13:21 +0000 Subject: [PATCH 3/3] test(types): make the catalog-membership fixture set self-sufficient against a raw-code pass-through (#8124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverse verification showed the membership test's fixtures produced only invalid_type — spelled identically in Zod's vocabulary and the catalog — so the test stayed green against the exact leak it exists to refuse. The unknown-node-key fixture makes it red under a pass-through on its own. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Euoy6wyfzgiWtgCg4s6JK2 --- packages/types/src/validation-failure.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/types/src/validation-failure.test.ts b/packages/types/src/validation-failure.test.ts index 1d047daf49..626a343020 100644 --- a/packages/types/src/validation-failure.test.ts +++ b/packages/types/src/validation-failure.test.ts @@ -57,7 +57,13 @@ describe('fieldsFromZodIssues — ADR-0114 D3 catalog codes, not Zod codes (#812 [MarkNotificationsReadRequestSchema, { notificationIds: ['n1'] }], [MarkNotificationsReadRequestSchema, { ids: 'n1' }], [MarkNotificationsReadRequestSchema, []], - // The flow contract the automation domain parses (#8055). + // The flow contract the automation domain parses (#8055). The + // unknown-key fixture is the load-bearing one: reverse-verifying + // this file showed the OTHER fixtures produce only `invalid_type`, + // which Zod and the catalog spell identically — so without a + // fixture whose Zod code is outside the catalog, this test stayed + // green against the raw pass-through it exists to refuse. + [FlowSchema, { ...WELL_FORMED_FLOW, nodes: [{ id: 'n', type: 'notify', label: 'Notify', next: 'other' }] }], [FlowSchema, { ...WELL_FORMED_FLOW, nodes: [{ id: 'n', type: 'notify', config: {} }] }], [FlowSchema, 'not even an object'], ];