fix(spec): fold FormViewSchema.groups into sections at the producer (#6926) - #7128
Conversation
…6926) `FormViewSchema` declared `groups` as "Legacy support -> alias to sections" and nothing in this repo performed the fold. The alias was honored exactly one boundary downstream, inside ObjectUI's renderer, so a `groups`-authored public form rendered in the console and degraded on all three REST public-form routes, which read `sections` only. The fold now happens at the producer, as a `.overwrite()` on `FormViewSchema`: groups-only folds onto `sections`, `sections` wins when both are present (the renderer's own `sections ?? groups` rule, so nothing that renders today renders differently), and `groups` is absent from every parsed form. Declared once and inherited by every parse door — `ViewSchema.form` / `.formViews.*`, both `ViewItemSchema` form arms, and `ViewMetadataSchema`'s container and flattened form-overlay members. `.overwrite()` rather than `.transform()`, measured: a transform returns a ZodPipe, which breaks `FormViewSchema.extend()` for the overlay member and makes `overlayTypeValues()` answer with an empty set — a silent mis-dispatch. The acceptance face is unchanged: `groups` stays a legal authoring key, and the `pane` refinement still reports `groups.0.pane`, the path the author wrote. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
…tions` (#6926) Verified first-hand against objectui @5e52495 while confirming the precedence this fold was told to pin. `spec-bridge/bridges/form-view.ts` uses `spec.sections ?? spec.groups`, so an EMPTY `sections` wins; `plugin-form/ ObjectForm.tsx` gates on `!folded.sections?.length`, so there the alias wins when `sections` is empty. The fold picks the primary path's answer — an alias is consulted when the canonical key is absent, and a fallback-on-empty is the lenient-consumer shape this change exists to remove. Also records that ObjectForm's fold rewrites sub-keys (title -> label, defaultCollapsed -> collapsed) as a renderer-local adaptation, so nobody later mistakes it for spec semantics and reproduces it here. Comment only; no behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 106 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:
|
Closes #6926
FormViewSchemahas declaredgroupswith the inline comment "Legacy support -> alias to sections" for as long as it has existed, and nothing in this repo performed the fold. This adds it at the producer, so the declared alias becomes true for every consumer of a parsed form at once.Ruling chain
pm:queuewith the direction retire the alias per ADR-0049, on the stated basis thatgroups"has zero in-repo producers and zero consumers".packages/spec/liveness/view.jsonalready recorded the keylive. No code shipped.Option A as escalated: Enforce — add a
.transform()onFormViewSchemafoldinggroupsontosectionsat the PRODUCER, so the declared alias becomes true for every consumer at once, REST included. objectui's five boundary normalizations become dead code and retire naturally in a later, separate lap. No ADR-0087 retirement kit needed.What the fold does
groups: [...]groups: [...], nosectionssections: [...],groupsabsentsections(the authored one),groupsabsentsections: [...]sectionswins when both are present — deliberately the renderer's own primary-path rule (spec.sections ?? spec.groupsinspec-bridge/bridges/form-view.ts), so nothing that renders today renders differently.??treats an empty array as present, and so does the fold.spec-bridgeuses??(emptysectionswins), whileplugin-form/ObjectForm.tsxgates on!folded.sections?.length(the alias wins whensectionsis empty). An alias is consulted when the canonical key is absent; fallback-on-empty is the lenient-consumer shape this change exists to remove.The acceptance face is unchanged —
groupsstays legal at input, still validated asFormSection[], and a misplacedpaneinside it is still reported atgroups.0.pane, the path the author actually wrote. Only parsed OUTPUT changes..overwrite(), not.transform()(measured)Both fold identically at parse, but
.transform()returns aZodPipe, and this schema is consumed as an object in two places a pipe breaks:FormViewOverlayWireSchemabuilds the flattened overlay member withFormViewSchema.extend(...)(a pipe has no.extend), andselectViewMetadataBranchreads._zod.def.shape.type— which a pipe answers with an empty set, i.e. silent mis-dispatch rather than an error. Measured on zod 4.4.3:.overwrite()keeps the schema aZodObjectand.extend()inherits the check, so one declaration reaches every parse door.Reverse verification — direction predicted BEFORE the run
Predictions were written to file before the fold was detached. Method: detach
.overwrite(...), re-run, restore withgit checkout HEAD --(nevergit stash).sectionsstill winsgroups→ emptysectionsgroupspinpanereports authored keyPredicted 12 RED / 3 GREEN — measured exactly 12 failed, 225 passed (237).
The three that stay GREEN are the load-bearing half: they are exactly the assertion shape the two pre-existing pins used, which is the measured reason an unfolded alias survived this file for its entire life. An acceptance-only pin cannot discriminate a fold.
REST public-form path — measured, deliberately NOT patched
The filing's guardrail (never
?? match.form?.groupsinrest-server.ts) is honored. The three/forms/:slugroutes resolve throughprotocol.getMetaItems({ type: 'view' }), which has two sources with different answers:defineView/defineForm→ViewSchema.parse/FormViewSchema.parse→ registrysys_metadata→convertStoredItem(ADR-0087 conversion chain, not a zod parse)So this fold closes the REST degradation for code-authored forms and does not reach runtime-saved rows. That remainder is reported, not improvised.
Placement recommendation for the remainder (follow-up, not this PR):
saveMetaItemalready parses a view throughViewMetadataSchema— so with this change it already computes the fold — and then discardsparsed.datato keep Studio round-trip keys. There is an exact in-repo precedent for grafting one normalization back out of that discarded result:graftNormalizedOperators, added because otherwise "the alias tableVIEW_FILTER_OPERATOR_ALIASESkeeps acquiring new rows with every save, which is why it can never be retired: there is no point at which the last alias row is behind you." That argument transfers verbatim togroups. Note the existing helper walks values in lockstep by structure and cannot express a key move, so this needs a sibling, not a parameter.i18n-resolvergroupsbranch — KEPT, by measurementresolveMetadataFormLabelsis exported from@objectstack/spec/systemand takes any form-shaped object. Its one in-repo caller (rest-server.ts, translatinggetMetaTypes()entries) now feeds it post-parse registry forms, for which the branch is unreachable and a harmless no-op. But a storedsys_metadatabody still carries the authored key (see the table above), so a caller handing it a pre-parse form is not hypothetical — deleting the branch would silently drop translations for exactly those forms, which is the same "measured one consumer, missed the other" mistake this issue was filed for.Liveness ledger
.props.form.children.groupsrefreshed:verifiedAt: 2026-08-09,evidenceScope: cross-repo, producer evidence now cited, anchor drift corrected (ObjectForm.tsx:90→:129-142), and — the omission that let a live key be filed as dead — it now records that the framework REST path did not fold, plus which door still does not.Gates
pnpm lint(ESLint + family gates)tsc --noEmit(spec)TSC_EXIT=0check:spec-parsed-alias(ADR-0122)check:export-origins(new today)check:generatedcheck:generated --reconcile-onlyGEN2_EXIT=0check:livenesscheck:nul-bytescheck-adr-0087-registration --base origin/maingen:schema+gen:docscheck:api-surfacereported stale beforepackages/specwas built in this fresh worktree — the gate's own warning says it reads the builtdistand that such removals are phantoms. After building it reports "public API surface + factory signatures unchanged".ADR-0087 agreeing that this is non-breaking is the expected answer: a transform narrows parsed output, it is not a declared-breaking removal.
Notes for the reviewer
...-r2: the prior dev's partial3a69119was reused (rebased onto current main, then verified), but rebasing rewrote that commit, and pushing over it would have required a force-push. New branch name instead of force.ViewItemSchemaparse-door case usedname: 'account_edit', which the schema rejects (dotted snake_case required).FormViewParsedtype still declaresgroups?because.overwrite()does not change the type. The runtime contract is the enforced one; hand-narrowing the alias is not available since ADR-0122's gate requires it to readz.infer< typeof FormViewSchema >verbatim.Generated by Claude Code