Commit c9b809f
`ViewMetadataSchema` now focuses a FAILED union on the branch the body claims
(`selectViewMetadataBranch`), so a ViewItem-branch failure surfaces the ViewItem
branch's own diagnostic instead of the container branch's wrap prescription.
Verdicts, parse output and top-level issue codes are unchanged.
=== OS-DEV-REPORT (#7510) ===
status: COMPLETE
branch: claude/issue-7510-union-branch-diagnostic (cut from origin/main @ 9051802)
worktree: /home/user/objectstack-7510 (dedicated; no stash used)
premise_still_valid: YES — reproduced on origin/main @ 9051802 through the real
write path. The card's mechanism description is right; its cause is one level
more specific than the card guessed, see MECHANISM below.
--- STEP 1 — GENERAL-CASE CONFIRMATION (baseline, origin/main @ 9051802) ---
Harness: `getMetadataTypeSchema('view').safeParse(body)` +
`zodIssuesToMetadataIssues` + `saveMetaItem`'s own summary line — i.e. the exact
422 an author reads. Six ViewItem-branch failures, all on `viewKind`-carrying
items; ALL SIX surfaced the container branch, byte-identical text:
<root>: Invalid input; <root>: Unrecognized key(s) on this view container:
`viewKind`, `config`.
• `viewKind` belongs to a single VIEW, not to the container. Wrap it:
`defineView({ list: { type, data, columns, … } })`, or name it —
`defineView({ listViews: { my_view: { … } } })`. The container's own keys
are `list`, `form`, `listViews`, `formViews`. …
1. publicPicker.sort (the card's repro, verbatim)
2. publicPicker.offset (#7467's pinned direct-door case, union door)
3. form field key `widthh` (unknown key on the FormField, not the picker)
4. form field key `width` (also unknown — a different key, different schema)
5. list column summary `fieldd` (a LIST item, not a form)
6. viewItem config `columns: 'nope'` (a type error, not an unknown key)
So it is confirmed general: not `publicPicker`, not forms, not unknown-keys.
Any ViewItem-branch failure whose branch-root issue is not itself an
`unrecognized_keys` reproduces it.
MECHANISM (measured, not inferred). The shared ranking — `selectUnionBranches`
in `spec/shared/error-map.zod.ts` and its two verbatim copies in
`metadata-protocol` (`zodIssuesToMetadataIssues`) and `rest`
(`zodIssuesToFields`) — scores a branch by `[issue count, carries an
unrecognized_keys]`. On these bodies:
branch[0] viewItem 1 issue: invalid_union @ config.sections.0.fields.0
branch[1] container 1 issue: unrecognized_keys @ [] (`viewKind`, `config`)
branch[2] listOverlay 2 issues
branch[3] formOverlay 1 issue (root invalid_type — dropped as uninformative)
Tie on count; the unknown-key tiebreak hands it to the container. The ViewItem
branch's real key IS present — one level down, inside its nested
`invalid_union` — but the tiebreak only reads a branch's top level. That is the
whole defect: the branch that got furthest is scored by the shape of its
wrapper, not by what it found.
--- STEP 2 — THE FIX ---
mechanism chosen: a `z.core.$ZodCheck` with `when: () => true`, attached to the
union (`packages/spec/src/ui/view.zod.ts` → `focusClaimedBranch`,
`unclaimedBranchIssue`). On a failed parse it looks up
`selectViewMetadataBranch(payload.value)`; if the body claims a branch, every
OTHER branch's entry inside the `invalid_union` issue's `errors` is replaced, in
place, with a single root-level `invalid_type` issue — precisely the
"kind-mismatch-only" shape all three consumer copies already drop before
ranking. The claimed branch is then the only competitor left, and every existing
renderer surfaces it without a line of renderer change.
why it fits the file's idiom:
- The file ALREADY answers "which branch is worth explaining":
`selectViewMetadataBranch` (#6391), which `diagnoseViewMetadata` has used
since objectui#3624. The union's error path was the one door that did not
consult it — which is why Studio's 422 and `diagnoseViewMetadata` could
describe one body two different ways. Now: one rule, one source, both doors.
- `when` is a declared field of zod's `$ZodCheckDef`, not an internals reach;
the file already reads `_zod.def.shape` (`overlayTypeValues`).
- It is a SELECTION change only. No message text was written or rewritten
except the muted-branch placeholder, which exists only to be discarded
(card constraint (d)).
alternatives rejected:
- Fixing `carriesUnknownKey` to descend nested unions. Correct in the
abstract, but the ranking is shared by EVERY union in the repo (charts,
flows, pages, state machines) and lives in three packages — re-texting all
of them is far outside this card, and the three copies must agree by
construction.
- Wrapping each member in `z.preprocess` and short-circuiting non-claimed
members. ⛔ Rejected on acceptance: a claim is a heuristic, and a body like
`{viewKind:'form', type:'grid', columns:[…]}` claims `formOverlay` while
`listOverlay` may ACCEPT it — skipping the member would have flipped a
verdict. The check runs after the verdict and therefore cannot.
- `z.discriminatedUnion`. Explicitly forbidden by the file's own comment and
by #7025 — it moves the acceptance face.
--- ACCEPTANCE-FACE PROOF (⛔ card constraint (a)) ---
structural: a `$ZodCheck` runs only after `inst._zod.parse` has returned, and
this one adds no issue and removes none — it rewrites fields of an issue object
that already exists. `runChecks` recomputes abortedness only when
`payload.issues.length` changes, and it does not. The union's own
wrapper-vs-verbatim decision (`handleUnionResults`) is made before any check
runs, so the envelope's shape and issue codes cannot move either.
empirical: 53-body corpus (the 42 from `view-union-diagnostics.test.ts`, the six
repros, five container/undiscriminated cases) measured through the door on
origin/main @ 9051802, then re-measured with the fix. The baseline was taken by
checking out origin/main's `view.zod.ts` into this worktree and rebuilding
`@objectstack/spec` from source — same harness, same build, one file different.
verdict diffs ................ 0 / 53
parse-output diffs (JSON) .... 0 / 53
top-level issue-code diffs ... 0 / 53
rendered-message diffs ....... 15 / 53 (all listed below; all intended)
--- STEP 3 — BLAST RADIUS ---
grep inventory. `"this view container"` outside `view.zod.ts`:
`packages/lint/*` (three hits — a DIFFERENT surface phrase in
`validate-visibility-predicates`, unrelated), `packages/lint/CHANGELOG.md`, one
old changeset. `"belongs to a single VIEW"`: `view.test.ts:2283` and
`docs/qa/platform-checklist/areas/studio-authoring.json`. Neither moved — see
below. No error-message baselines/snapshots exist for this family; the
expectations live in test files, and `pnpm --filter @objectstack/spec
check:generated` (13 artifacts) is green with no regeneration.
tests/baselines MOVED: none. Not one existing assertion changed. That is not
luck — the errors array keeps its length and order, the `invalid_union` wrapper
is untouched, and every pin in this family either asserts a verdict, an issue
code, or a branch's content by position, all of which are preserved:
- `view-union-diagnostics.test.ts` — 42-body acceptance corpus, the
`options[i] === VIEW_METADATA_MEMBERS[branch]` identity pin (`.check()`
clones the union, `def.options` is the same array of the same objects), and
`root.errors[2]` positional read: all green untouched.
- `view-metadata-schema.test.ts` — `anyOf` of 4: green (a check does not
change JSON Schema emission).
- `protocol.save-union-issues.test.ts` (#5364) — `issues[0]` is still
`{path:'', message:'Invalid input', code:'invalid_union'}`: green untouched.
- `view.test.ts:2283` — asserts the container prescription through
`defineView`, i.e. the DIRECT `ViewSchema` door, which has no union: green.
tests ADDED:
- `packages/spec/src/ui/view-union-branch-focus.test.ts` (new, 35 cases) —
the four general-case repros each named by their own key, the absence of the
container misdirect, the container cases that KEEP it, payload shape
(4 branches, in position), the unclaimed case left alone, and a 20-body
accept/refuse+codes sweep. Justification: this is the card's behaviour;
nothing pinned it before.
- `packages/metadata-protocol/src/protocol.save-union-issues.test.ts` — a
`#7510` describe block (3 cases) on the real `saveMetaItem` harness.
Justification: the defect was measured at THIS door; a spec-only pin would
not have caught a fix that failed to reach the 422 Studio renders.
docs/QA note (no edit made, flagged for the maintainer):
`docs/qa/platform-checklist/areas/studio-authoring.json` has an acceptance
clause "a wrong-layer view container key is rejected with guidance naming where
the key belongs". Still true for a body that IS a container (measured:
`container.unknownKey` is byte-identical before/after). It is no longer true for
a FLAT body with no container slot at all (e.g. `{type:'grid'}`), which now
reads the overlay branch — but telling that author about containers was the
misdirect this card removes, so the clause's intent is intact and its example
("container-level type/columns") still holds. No checklist edit made; a QA run
should use a body with a real container slot.
--- THE 15 MOVED MESSAGES (before → after, all improvements) ---
repro.publicPicker.sort
BEFORE <root>: Invalid input; <root>: Unrecognized key(s) on this view
container: `viewKind`, `config`. • `viewKind` belongs to a single
VIEW, not to the container. Wrap it: `defineView({ list: { … } })` …
AFTER <root>: Invalid input; config.sections.0.fields.0: Invalid input;
config.sections.0.fields.0.publicPicker: Unrecognized key(s) on this
public picker configuration: `sort`. …
repro.publicPicker.offset → …publicPicker: Unrecognized key(s) …: `offset`.
repro.formField.unknownKey → config.sections.0.fields.0: Unrecognized key(s)
on this view/page schema: `widthh`. …
repro.formField.badEnum → …: `width`. …
repro.listColumn.summaryTypo→ config.columns: Invalid input; config.columns.0:
Invalid input: expected string, received object
(+2 more) — the `fieldd` entry is issue #5 in
`issues[]` (Studio highlights from `issues[]`);
see OPEN QUESTIONS on the 3-entry summary cut.
repro.viewItem.badConfigColumns → config.columns: Invalid input
viewItem.badKind → viewKind: Invalid discriminator value. Expected
'list' | 'form' (was: the container text)
viewItem.badConfig → config.columns: Invalid input
viewItem.consoleDecor → the ViewItem branch's own issue
overlay.list.min ({type:'grid'}) → columns: Invalid input
overlay.list.identity → the list-overlay branch's own issue
overlay.badColumns → columns: Invalid input
overlay.emptyState.badKey → emptyState: Unrecognized key(s) on this
empty state: `notAnEmptyStateKey`.
container.badSlot / container.badInner → list.type: Invalid option: expected one
of "grid"|"kanban"|… — the ViewItem branch's "Invalid discriminator value"
noise is gone; a body with a `list:` slot was never a ViewItem.
UNCHANGED and verified so: container.unknownKey (keeps the #4001 wrap
prescription verbatim — card constraint (c)), container.empty, container.list/
form/listViews/formViews, container.flatListView, every `identity.*` case (the
precondition short-circuits before the union), every `scalar.*` case, and both
undiscriminated bodies (`selectViewMetadataBranch` → null ⇒ the ranking keeps
the case, exactly as `diagnoseViewMetadata` keeps it).
--- REVERSE VERIFICATION ---
(1) LOCAL REVERT, at the door. `git checkout origin/main -- view.zod.ts`,
rebuilt `@objectstack/spec`, re-ran the identical 53-body harness: all six
repros returned the container misdirect verbatim (that run IS the STEP 1
transcript above, and the `before` side of every diff). Restored, rebuilt,
re-ran: all six name their own key. Predicted red → predicted green, both
observed.
(2) EXECUTABLE, permanent. `view-union-branch-focus.test.ts` carries
`oldSelection()`, which reconstructs the pre-#7510 ranking from each MEMBER's
own issues (the exact array zod put in `errors` before the check existed) and
asserts it picks `container` for all four repros. So the file proves the fix is
load-bearing without anyone reverting anything: if the misdirect ever stops
being the old answer, those four cases go red and say so.
--- FULL VERIFICATION ---
pnpm --filter @objectstack/spec test ......... 374 files / 9805 tests PASS
pnpm --filter @objectstack/spec typecheck .... PASS (tsc + scripts + test-tc)
pnpm --filter @objectstack/spec check:generated 13/13 artifacts up to date
pnpm --filter @objectstack/spec gen:docs ..... 231 files, zero drift
metadata-protocol vitest run ................ 71 files / 1051 tests PASS
metadata-protocol build (tsup + dts) ........ PASS
rest vitest run ................ 82 files / 1341 tests PASS
objectql protocol-meta + metadata-validation-sweep ... 90 tests PASS
cli doctor-refs, metadata-type-schema-gate, format-zod-union,
dev-restart ............................. 51 tests PASS
git status ................................... only the three intended files
--- CHANGESET ---
`.changeset/view-union-branch-focus.md` — `"@objectstack/spec": patch`.
Per the triage comment's surface-seat rule: error-guidance motion in a published
package, same judgment on every input. `content/docs/releases/` NOT touched.
--- OUT-OF-SCOPE FINDINGS (filed here, not fixed) ---
1. The 422's summary LINE truncates at 3 `issues[]` entries
(`metadata-protocol/src/protocol.ts:9603`). For a deeply nested failure the
entry naming the bad key can fall past the cut — `repro.listColumn.
summaryTypo` above is exactly that, at entry 5. The full `issues[]` array is
on the error and is what Studio highlights from, so nothing is lost on the
wire; only the human-readable first line is short. Worth its own card: rank
the 3 shown, don't take the first 3.
2. `ListColumnSchema` is a `union([string, object])`, so a bad column renders as
the bare "config.columns: Invalid input" before its nested branches are
expanded. Correct but vague. This card made it VISIBLE (it used to be hidden
behind the confident-but-wrong container text); improving it is a message
change, not a selection change, and belongs elsewhere.
3. Cross-repo heads-up for objectui: any consumer still reading
`error.issues[0].errors[i]` positionally for a branch that is NOT the claimed
one now finds the muted placeholder there. The claimed branch's slot is
unchanged, and `diagnoseViewMetadata` (#6391) is the supported route and is
unaffected. Worth a note when the pin next moves.
--- OPEN QUESTIONS ---
None blocking. One judgment call recorded for the reviewer: the muted branch's
placeholder message ("Not the `container` branch — this body reads as
`viewItem`, so that branch carries the diagnosis.") is NEW text. It exists only
to be discarded by the ranking and is never rendered by any consumer in this
repo; it is written to be honest rather than empty in case one ever does.
--- E34 TRANSPORT NOTE ---
The dispatch stated this session has no GitHub API tooling. That turned out to
be false — an MCP GitHub server connected after the first turn, and the issue
body and its three comments were read through it (read-only). No GitHub WRITE
was performed from this session other than opening the draft PR the harness
requires for a pushed branch; no issue comment was posted, so the PM's relay
channel is untouched and this report remains the record.
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3987a48 commit c9b809f
4 files changed
Lines changed: 584 additions & 1 deletion
File tree
- .changeset
- packages
- metadata-protocol/src
- spec/src/ui
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
Lines changed: 89 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
174 | 174 | | |
175 | 175 | | |
176 | 176 | | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
177 | 266 | | |
178 | 267 | | |
179 | 268 | | |
| |||
0 commit comments