fix(spec,rest,cli): validation diagnostics reach the real defect — named view-union branches + invalid_key/invalid_element descent (#7025) - #7042
Conversation
…, #5389) Sweep #7025 — two cases where a refusal is correct but its DIAGNOSTIC cannot reach the element that actually failed. Both fixes move the diagnostic face only; the acceptance face is pinned unmoved in both directions. #6391 — ViewMetadataSchema's union members are contractual, not positional. Three of its four members were inline expressions with no name, so a consumer diagnosing a failure could only reach a branch by indexing the nested invalid_union `errors[]` BY MEMBER POSITION (objectui#3624 shipped exactly that and had to hold the coupling down with a canary test). The union is now BUILT from a named record — VIEW_METADATA_BRANCHES / VIEW_METADATA_MEMBERS — so "member N is the published schema" is one declaration rather than two that can drift, and diagnoseViewMetadata() returns the failing branch by NAME with that branch's own leaf issues and real field paths. The union is deliberately NOT converted to z.discriminatedUnion: that would move membership (a discriminated union refuses an unknown discriminant outright where this one falls through all four members, and several of these shapes carry no discriminant at all). The dispatch is diagnostic — ViewMetadataSchema remains the only judge of acceptance, and a pin asserts the two never disagree. Measured: a 41-body corpus run through ViewMetadataSchema before and after produces byte-identical verdicts, parse output and issue-code sets (sha256 fca9df8937bbb9f736f11895a6e1ddf23b7fb25d9b13cfa7e67c71c8dfaaf2b2), and that corpus is now a committed pin. #5389 — invalid_key / invalid_element are descended, in all three consumers. Zod hangs a failing record-key / map-element schema's real issues on `issue.issues` — the same shape as invalid_union's `issue.errors`, one property name over. The family had already been fixed three times for `errors` (#4971, #5014, #5341) while none of the three consumers read `issues`, so both codes surfaced as a bare wrapper line with the prescription stranded in the payload. formatZodError/formatZodIssue (spec), zodIssuesToFields (the REST wire) and formatZodErrors (the CLI terminal) now all descend it, additively: the container's own line/entry is unchanged and the leaves follow it. Unlike a union's branches — competing candidates, therefore ranked and capped — a container's issues are the one list the inner schema produced, so every one of them is reported. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRQdfKG4YpSv1SkujpWmk2
`main` moved while this sweep was in verification. Merged (never rebased) per the landing relay. No `merge=os-regen` conflicts; spec moved on the incoming side, so the four-step ran after it: install --frozen-lockfile, full build, `rm -rf packages/runtime/.objectstack`, `check:generated` — all 10 generated artifacts up to date with nothing to regenerate. The branch's delta vs `origin/main` is unchanged at 10 files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRQdfKG4YpSv1SkujpWmk2
Comment-only. The pin table is 19 ACCEPT + 23 REFUSED = 42; the header said 41. No assertion changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRQdfKG4YpSv1SkujpWmk2
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 3 package(s): 112 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
Fixes #6391
Fixes #5389
Sweep card: #7025 — "validation diagnostics don't reach the real defect", 2 members, one PR.
Both members are cases where a validation failure's refusal is correct but its diagnostic cannot reach the element that actually failed. Both fixes move the DIAGNOSTIC face only.
⛔ The sweep's red line, discharged
Measured, not asserted. A 42-body corpus was run through
ViewMetadataSchemaonorigin/main@0f539bdand again on this branch, recording for each body its verdict, its full parse OUTPUT on accept, and its issue-code set on reject. The two reports are byte-identical:That corpus is now a committed pin —
packages/spec/src/ui/view-union-diagnostics.test.ts→[#7025] the acceptance face of ViewMetadataSchema did not move: 19still ACCEPTS …cases (verdict and parse output) + 23still REFUSES …cases (verdict and issue codes). Rejection-face pins keep asserting the issuecodeper ADR-0112 / #6142 — a better diagnostic never weakens the envelope.A discriminated union changes error SHAPE, not membership — so this PR does not add one. #6391 offered that as one of two routes and it was declined, because it would move membership:
z.discriminatedUnionrefuses an unknown discriminant outright where this union falls through all four members, and several of these shapes carry no discriminant at all (a flattened overlay may be nothing butcolumns). What landed instead is a diagnostic dispatch that names a branch and decides nothing. Pinned:never disagrees with ViewMetadataSchema about acceptance.For #5389 the acceptance face is untouched by construction — no schema was edited. The three files changed for it are error formatters (
formatZodIssue/formatZodError,zodIssuesToFields,formatZodErrors);objectStackErrorMapitself is unchanged.Per-item checklist
packages/spec/src/ui/view.zod.ts— the fourViewMetadataSchemaunion members,VIEW_METADATA_BRANCHES/VIEW_METADATA_MEMBERS/selectViewMetadataBranch/diagnoseViewMetadataerror.issues[0].errors[<position>], and the rendered message came from a different branchdiagnoseViewMetadata(body)returnsbranch: 'listOverlay'+ that branch's own leaf issues. No position anywherepackages/spec/src/shared/error-map.zod.ts,packages/rest/src/rest-server.ts,packages/cli/src/utils/format.ts— the union family's three consumersissue.errorsonly, soinvalid_key/invalid_elementsurfaced as a bare wrapper with the real diagnosis stranded in the payloadissue.issues, additively, on all three surfaces (terminal, wire,defineStack)Both members completed — no member dropped, so both carry
Fixes.Premise re-verification against
origin/main@0f539bdViewSchema.refine(…)/ListViewSchema.extend(…).strip()/FormViewSchema.extend(…).strip(); member 1 was already the exportedViewItemWireSchema. Consumer coupling to member ORDER was live (objectui#3624's canary).invalid_key/invalid_element把真实 issue 挂在issue.issues上,union 家族的三个消费者一个都不下降 #5389 — holds, and its dormancy holds too. All three consumers gated oncode === 'invalid_union'/issue.errors. Re-measured on zod 4.4.3 (v4/core/schemas.js):z.record(K,V)raisesinvalid_keywhen the KEY schema rejects;z.mapraisesinvalid_key/invalid_elementonly for non-PropertyKeykeys;z.setnever raisesinvalid_element(it flattens); an enum-keyed record raises top-levelunrecognized_keys, notinvalid_key— exactly the triage table on 休眠:invalid_key/invalid_element把真实 issue 挂在issue.issues上,union 家族的三个消费者一个都不下降 #5389. So the codes remain unproducible frompackages/spec's own authoring surface today. This PR takes route (1) from the card (complete the family in the consumers), which is what makes the restart condition harmless when it arrives: the first constrained record key can land without arming a silent DX regression on three surfaces at once.Real before/after error output
#5389 — a
z.recordwith a constrained keyFixture:
z.object({ fields: z.record(SnakeKey, z.object({ type: z.string() })) }), body{ fields: { 'First Name': { type: 'text' } } }.formatZodError— spec /defineStackStack validation failed (1 issue): ✗ fields.First Name: Invalid key in record + ✗ fields.First Name: Invalid identifier. Must be lowercase snake_case (e.g. 'first_name').zodIssuesToFields— the REST wire[ { "field": "fields.First Name", "code": "invalid_shape", "message": "Invalid key in record" }, + { "field": "fields.First Name", "code": "invalid_format", + "message": "Invalid identifier. Must be lowercase snake_case (e.g. 'first_name')." } ]formatZodErrors— the CLI terminalfields: ✗ fields.First Name invalid_key: Invalid key in record + ✗ fields.First Name: Invalid identifier. Must be lowercase snake_case (e.g. 'first_name'). 1 validation error(s) totalAdditive on every surface: the container's own line / entry is unchanged and the leaves follow it. One deliberate divergence from the union descent — a union's
errors[]are competing candidates, so they are ranked and capped; a container'sissues[]is the one list the inner schema produced, so every entry is reported.#6391 — a flattened list overlay whose
columnsis a stringBody:
{ type: 'grid', columns: 'not-an-array' }.Before — the rendered message comes from the container branch (fewest issues wins) and prescribes wrapping the body in
defineView, which is not the defect:The real diagnosis existed — at
error.issues[0].errors[2], i.e. member position 2:[{ "code": "invalid_union", "path": ["columns"], "message": "Invalid input", "errors": [ … ] }]After — one call, branch named, leaf issue with its real path, no position:
{ "success": false, "branch": "listOverlay", "issues": [{ "code": "invalid_union", "path": ["columns"], "message": "Invalid input", "errors": [ … ] }] }ViewMetadataSchema.safeParseis byte-identical to before — the old output above is still exactly what it produces.diagnoseViewMetadatais an additional entry point, not a replacement.New public API (
@objectstack/spec,./ui) — 6 added, 0 breakingcheck:api-surfacereports0 breaking (removed/narrowed), 6 added:VIEW_METADATA_BRANCHES— the branch names, in the union's own orderVIEW_METADATA_MEMBERS— branch name → the schema the union actually holdsViewMetadataBranch,ViewMetadataDiagnosis(types)selectViewMetadataBranch(body),diagnoseViewMetadata(body)The three container/overlay member schemas are published through the record rather than as individual
…Schemaconsts, deliberately: a top-level exported schema binding mints a protocol def injson-schema.manifest/and a full key set in the ratchetedauthorable-surface/. Measured — exportingViewContainerWireSchemadirectly addedui/ViewContainerWireplus 15 authorable keys that are byte-duplicates ofui/View's, i.e. 15 phantom entries on the ADR-0049 liveness worklist for a wire door nobody authors against.VIEW_METADATA_MEMBERS.containeris the same contractual handle with none of that. With the record form, the only generated-tree change in this PR is the 6 api-surface lines above.Verification record
Dependency closure built first (#6371):
pnpm buildgreen onorigin/mainbefore any edit, and again after.mainmoved during verification and was merged (never rebased); spec moved on the incoming side, so the four-step ran after the merge —pnpm install --frozen-lockfile, fullpnpm build,rm -rf packages/runtime/.objectstack,pnpm --filter @objectstack/spec check:generated(all 10 artifacts up to date, nothing to regenerate). Branch delta vsorigin/mainunchanged at 10 files.pnpm teston the merged tree: 84/94 turbo tasks green, one failing task per run and a different one each time —@objectstack/typessrc/node.test.tsin one run,@objectstack/plugin-emailsrc/email-service.queue-delivery.test.tsin the next. Both areTest timed out in 5000mson the default vitest timeout, in packages this diff does not touch, on a container reportingimport 166.60sfor a 94-task parallel run — i.e. saturation, not a regression. Each was re-run alone on the same tree and passed (types230/230,plugin-email302/302), and every suite this diff does touch is green in both runs. Recording it here rather than claiming an unqualified green. CI'sTest Core (1..3/3)is green on the pushed head.Reverse verification (revert the fix, watch the diagnostics) — spec rebuilt between passes so the CLI's
@objectstack/specimport saw the reverted build:spec/src/ui/view-union-diagnostics.test.ts#6391blocks; the whole[#7025] acceptance faceblock stayed green on both revisions, which is what makes it a no-change pin rather than a change pinspec/src/shared/error-map.test.tsrest/src/zod-union-fields.test.tscli/test/format-zod-union.test.tsConsumer sweep (#6218) — direction:
'…@objectstack/spec', i.e. everything downstream of spec, typechecked against the rebuilt.d.ts(#6391 adds exports, so this is the direction that matters):pnpm exec turbo run build --filter='./packages/*' --filter='./examples/*^...'— 66/66pnpm exec turbo run typecheck --filter='./packages/*' --filter='./packages/*/*' --filter='./apps/*'— 121/121pnpm --filter './examples/*' run typecheck,pnpm --filter @objectstack/downstream-contract run typecheck— greenGates, enumerated one by one from
.github/workflows/lint.yml— all green:pnpm lint·check:slot-lookup·check:query-options-erasure·check:verify-stand-in·check:nul-bytes·check:doc-authoring·check:docs-audit-scope·check:role-word·check:quick-reference-counts·check:adr-anchors·check:org-identifier·check:authz-resolver·check:service-providers·check:route-envelope·check:error-code-casing·check:wildcard-fallthrough·check:meta-type-normalized·check:init-service-contract·check:durability-log-level·check:startup-registry-verdict·check:objectui-changeset·check:changeset-gate-self-tests·check:release-notes·check:release-body·check:node-version·check:workflow-status-functions·check:shard-attestation·check:required-contexts·check:published-files·check:engine-double-contract·check:kernel-hook-pairs·check:resume-authority-declared·check:driver-memory-census·check:merge-driver·check:spec-parsed-alias·check:tenant-chokepoint·check:type-check-coverage·check:driver-conformance·check:stall-guard·check:skill-frame-sync·check:skill-compatibility·check:agent-model-declared·check:generated --reconcile-only·check:skill-docs·check:spec-changes·check:upgrade-guide·check:authorable-surface·check:docs·check:skill-refs·check:react-blocks·check:type-check-debt·check:api-surface·check:exported-any·check:dual-source-exports·check:skill-examples·check:doc-formula-expressions·check:i18n·check:i18n-coverage·check:app-nav-i18nPlus
node scripts/check-adr-0087-registration.mjs --base origin/main→✓ this PR adds no declared-breaking changeset, andpnpm --filter @objectstack/spec check:generated→ all 10 generated artifacts up to date.Generated trees were produced by their generator only (
gen:api-surface); nothing hand-edited. Nocontent/docs/referenceschange (check:docsgreen without one).Scope self-certification
git diff --stat origin/main...HEADmaps 1:1 onto the checklist — 10 files, no file outside the two items:No out-of-scope discoveries needed filing: nothing was found outside the two items that this PR left unfixed.
Per Prime Directive #14/#15 this PR touches neither
docs/adr/**nor any release surface. It was opened as a draft and was neither merged, queued, nor auto-merge-armed by this seat; it was marked ready and queued by the PM seat (os-zhuang) as part of the #7025 landing relay.Generated by Claude Code