Skip to content

Commit 1dd780f

Browse files
os-zhuangclaude
andauthored
fix(spec): fold FormViewSchema.groups into sections at the producer (#6926) (#7128)
* fix(spec): fold FormViewSchema.groups into sections at the producer (#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 * docs(spec): record that the two objectui folds disagree on empty `sections` (#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 * test(spec): use a valid dotted ViewItem name in the #6926 parse-door pin * docs(spec): regenerate view reference for the folded groups alias (#6926) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 62dd69a commit 1dd780f

6 files changed

Lines changed: 320 additions & 4 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
fix(spec): `form.groups` is folded onto `form.sections` at the producer — the declared alias is now true for every consumer (#6926)
6+
7+
`FormViewSchema` has declared `groups` with the inline comment *"Legacy support
8+
-> alias to sections"* for as long as it has existed, and nothing in this repo
9+
performed the fold. The alias was honored exactly **one boundary downstream**,
10+
inside the renderer (ObjectUI's `spec-bridge` reads `spec.sections ??
11+
spec.groups`, and `plugin-form/ObjectForm` carries a full legacy fold — shipped
12+
because a `groups`-only spec once rendered nothing at all). Every framework
13+
consumer that is not that renderer read `sections` only.
14+
15+
So one authored form behaved two ways. A `groups`-authored **public** form
16+
rendered correctly in the console and degraded on all three REST public-form
17+
routes at once, because each of them walks `sections`:
18+
19+
- `GET /forms/:slug` published an empty field schema,
20+
- `POST /forms/:slug/submit` computed an empty `allowedFields` whitelist,
21+
- `GET /forms/:slug/lookup/:field` answered `403 LOOKUP_NOT_PUBLIC` for every
22+
field.
23+
24+
The fix is at the producer, not in the consumers: `FormViewSchema` now folds
25+
`groups` onto `sections` at parse, so every consumer of a parsed form sees one
26+
key. Teaching each consumer a second key to read was the other option and was
27+
rejected — a lenient consumer is where authored (especially AI-authored)
28+
metadata errors hide, and it leaves the next consumer blind.
29+
30+
**What changes.** Only the parsed OUTPUT of a form view:
31+
32+
| Authored | Parsed before | Parsed now |
33+
| --- | --- | --- |
34+
| `groups: [...]` | `groups: [...]`, no `sections` | `sections: [...]`, no `groups` |
35+
| both keys | both, verbatim | `sections` (the authored one), no `groups` |
36+
| `sections: [...]` | unchanged | unchanged |
37+
38+
`sections` wins when both are present — deliberately the renderer's own
39+
`sections ?? groups` rule, so nothing that renders today renders differently.
40+
`??` treats an empty array as present, and so does the fold.
41+
42+
**What does not change.** The acceptance face: `groups` is still a legal
43+
authoring key, still validated as `FormSection[]`, and a misplaced `pane` inside
44+
it is still reported at `groups.0.pane` — the path the author actually wrote.
45+
Consumers that read metadata *before* it is parsed are unaffected and still read
46+
both keys, which is correct for them: `os lint`'s view rules walk authored
47+
sources, and a `sys_metadata` row is persisted verbatim and re-read through the
48+
ADR-0087 stored-row conversion chain rather than through a Zod parse. The fold
49+
narrows output; it never narrows what is accepted.
50+
51+
The fold is declared once and inherited by every parse door — `FormViewSchema`
52+
itself, `ViewSchema.form` and `.formViews.*`, both `ViewItemSchema` form arms,
53+
and `ViewMetadataSchema`'s container and flattened form-overlay members.
54+
55+
If you read `.groups` off a **parsed** form view, read `.sections` instead; it
56+
now carries what `groups` used to.

content/docs/references/ui/view.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ Column footer summary configuration
262262
| **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Modal size (modal forms) |
263263
| **data** | `{ provider: 'object'; object: string } \| { provider: 'api'; read?: object; write?: object } \| { provider: 'value'; items: any[] } \| { provider: 'schema'; schemaId: string; schema?: Record<string, any> }` | optional | Data source configuration (defaults to "object" provider) |
264264
| **sections** | `{ name?: string; label?: string \| Record<string, string>; description?: string; collapsible?: boolean; … }[]` | optional | |
265-
| **groups** | `{ name?: string; label?: string \| Record<string, string>; description?: string; collapsible?: boolean; … }[]` | optional | |
265+
| **groups** | `{ name?: string; label?: string \| Record<string, string>; description?: string; collapsible?: boolean; … }[]` | optional | [LEGACY ALIAS → `sections`] Accepted for back-compat and folded onto `sections` at parse; `sections` wins when both are present. Prefer `sections`. |
266266
| **subforms** | `{ childObject: string; relationshipField?: string; columns?: any[]; amountField?: string; … }[]` | optional | Inline master-detail child collections |
267267
| **defaultSort** | `never` | optional | [REMOVED] `form.defaultSort` was removed in @objectstack/spec 17.0.0 (#3896 audit close-out) — nothing read it: a related list inside a form sorts by its own list view's `sort`. Delete the key and set the sort on the related list view instead. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
268268
| **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form |

packages/spec/liveness/view.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,10 @@
293293
},
294294
"groups": {
295295
"status": "live",
296-
"note": "objectui: legacy alias of sections, normalized at ObjectForm.tsx:90 and spec-bridge form-view.ts:169 (spec.sections ?? spec.groups). Audit L24 drift resolved by alias-at-one-boundary."
296+
"verifiedAt": "2026-08-09",
297+
"evidenceScope": "cross-repo",
298+
"evidence": "packages/spec/src/ui/view.zod.ts (foldFormGroupsIntoSections + the .overwrite on FormViewSchema — the PRODUCER fold, #6926: groups-only folds onto sections, sections wins when both are present, and groups is absent from every parsed form); objectui: packages/react/src/spec-bridge/bridges/form-view.ts:169 packages/plugin-form/src/ObjectForm.tsx:129-142 (objectui @7b3e048 — the pre-existing boundary folds, now redundant for parsed forms and retiring in a later cross-repo lap)",
299+
"note": "WHERE THE FOLD LIVES: at the producer, as of #6926 — a `.overwrite()` on FormViewSchema, inherited by every parse door (ViewSchema.form/.formViews.*, both ViewItemSchema form arms, and ViewMetadataSchema's container AND flattened form-overlay members, which picks it up through .extend()). Before #6926 the alias was declared but folded NOWHERE in this repo: it was honored one boundary downstream in objectui (alias-at-one-boundary, objectui#2545, after a groups-only spec rendered nothing), while the framework's REST public-form routes read `sections` only — so the same authored form rendered in the console and degraded on GET /forms/:slug, POST /forms/:slug/submit and GET /forms/:slug/lookup/:field. That omission is what let #6926 be filed reading a live key as dead; it is recorded here so the next reader cannot repeat it. STILL NOT FOLDED (measured 2026-08-09, #6926): a RUNTIME-saved sys_metadata row — saveMetaItem validates through ViewMetadataSchema and then deliberately discards parsed.data to keep Studio round-trip keys, and the read replays the ADR-0087 stored-row conversion chain, which is not a zod parse. Code-authored views (defineView/defineForm → registry) DO reach REST folded. Pre-parse consumers still read the authored key and are right to: packages/lint's view rules walk authored sources. Audit L24 drift resolved by alias-at-one-boundary; superseded by the producer fold."
297300
},
298301
"subforms": {
299302
"status": "live",

packages/spec/src/system/i18n-resolver.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1815,6 +1815,19 @@ export function resolveMetadataFormLabels<T extends Record<string, any>>(
18151815
next.sections = form.sections.map(translateSection);
18161816
}
18171817
// Legacy alias — some forms use `groups` instead of `sections`.
1818+
//
1819+
// KEPT after #6926 folded `groups` onto `sections` at the producer, and the
1820+
// measurement is why. This helper takes ANY form-shaped object (`T extends
1821+
// Record<string, any>`), and it is exported: its one in-repo caller
1822+
// (`rest-server.ts` translating `getMetaTypes()` entries) now feeds it
1823+
// post-parse forms from `METADATA_FORM_REGISTRY`, all of them `defineForm`
1824+
// outputs, so for THAT caller the branch is unreachable — but a stored
1825+
// `sys_metadata` body still carries the authored key (`saveMetaItem` keeps
1826+
// the body verbatim and the read replays the ADR-0087 conversion chain, not
1827+
// a zod parse), so a caller handing this a pre-parse form is not a
1828+
// hypothetical. Deleting the branch would silently drop translations for
1829+
// exactly those forms — the same "measured one consumer, missed the other"
1830+
// mistake #6926 was filed for. It retires when the stored shape folds too.
18181831
if (Array.isArray(form.groups)) {
18191832
next.groups = form.groups.map(translateSection);
18201833
}

packages/spec/src/ui/view.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ import {
3838
type ViewData,
3939
type HttpRequest,
4040
defineView,
41+
defineForm,
42+
ViewItemSchema,
43+
ViewMetadataSchema,
4144
} from './view.zod';
4245

4346
import {
@@ -538,6 +541,12 @@ describe('FormViewSchema', () => {
538541
};
539542

540543
expect(() => FormViewSchema.parse(formView)).not.toThrow();
544+
// #6926 — acceptance is unchanged; the OUTPUT is what changed. This pin
545+
// asserted acceptance only, which is exactly why it stayed green through
546+
// the whole life of the unfolded alias. Say what the parse now produces.
547+
const parsed = FormViewSchema.parse(formView) as Record<string, unknown>;
548+
expect(Object.prototype.hasOwnProperty.call(parsed, 'groups')).toBe(false);
549+
expect((parsed.sections as Array<{ label?: string }>)[0]?.label).toBe('Account Details');
541550
});
542551

543552
it('should accept tabbed form view', () => {
@@ -659,6 +668,132 @@ describe('FormViewSchema', () => {
659668
});
660669
});
661670

671+
/**
672+
* #6926 — `groups` is declared as an alias of `sections` and, until this
673+
* change, nothing folded it: the alias was honored one boundary downstream in
674+
* objectui's renderer, so the same authored form rendered in the console and
675+
* degraded on the framework's REST public-form routes (which read `sections`
676+
* only). The fold now happens at the PRODUCER.
677+
*
678+
* These cases discriminate the fold — the two pre-existing `groups` pins
679+
* (acceptance, and the `pane` error path) cannot, which is the measured reason
680+
* an unfolded alias survived this file for its whole life.
681+
*/
682+
describe('FormViewSchema — the `groups` legacy alias folds onto `sections` (#6926)', () => {
683+
const S = (label: string) => ({ label, fields: [label.toLowerCase()] });
684+
const has = (o: unknown, k: string) => Object.prototype.hasOwnProperty.call(o as object, k);
685+
686+
it('groups-only → sections, with `groups` absent from the output', () => {
687+
const parsed = FormViewSchema.parse({ type: 'simple', groups: [S('Account')] });
688+
expect(has(parsed, 'groups')).toBe(false);
689+
expect(parsed.sections?.map((s) => s.label)).toEqual(['Account']);
690+
});
691+
692+
it('both present → `sections` wins (the renderer\'s own `sections ?? groups` rule)', () => {
693+
const parsed = FormViewSchema.parse({
694+
type: 'simple',
695+
sections: [S('Canonical')],
696+
groups: [S('Legacy')],
697+
});
698+
expect(has(parsed, 'groups')).toBe(false);
699+
expect(parsed.sections?.map((s) => s.label)).toEqual(['Canonical']);
700+
});
701+
702+
it('an EMPTY `sections` still wins over a populated `groups` (`??`, not a merge)', () => {
703+
const parsed = FormViewSchema.parse({ type: 'simple', sections: [], groups: [S('Legacy')] });
704+
expect(has(parsed, 'groups')).toBe(false);
705+
expect(parsed.sections).toEqual([]);
706+
});
707+
708+
it('an empty `groups` folds to an empty `sections` (content, not absence)', () => {
709+
const parsed = FormViewSchema.parse({ type: 'simple', groups: [] });
710+
expect(has(parsed, 'groups')).toBe(false);
711+
expect(parsed.sections).toEqual([]);
712+
});
713+
714+
it('sections-only and neither-key forms are untouched', () => {
715+
expect(FormViewSchema.parse({ type: 'simple', sections: [S('Only')] }).sections?.[0]?.label)
716+
.toBe('Only');
717+
const bare = FormViewSchema.parse({ type: 'simple' });
718+
expect(has(bare, 'sections')).toBe(false);
719+
expect(has(bare, 'groups')).toBe(false);
720+
});
721+
722+
it('the ACCEPTANCE face is unchanged — `groups` stays legal at input', () => {
723+
expect(FormViewSchema.safeParse({ type: 'simple', groups: [S('Account')] }).success).toBe(true);
724+
// …and the fold does not launder an invalid section past the schema.
725+
expect(FormViewSchema.safeParse({ type: 'simple', groups: [{ label: 'No fields' }] }).success)
726+
.toBe(false);
727+
});
728+
729+
it('the `pane` refinement still reports the key the AUTHOR wrote', () => {
730+
const result = FormViewSchema.safeParse({
731+
type: 'simple',
732+
groups: [{ label: 'Account', pane: 'primary', fields: ['account_name'] }],
733+
});
734+
expect(result.success).toBe(false);
735+
if (!result.success) {
736+
expect(result.error.issues.some((i) => i.path.join('.') === 'groups.0.pane')).toBe(true);
737+
}
738+
});
739+
740+
describe('every parse door inherits the fold (one declaration, not per-door)', () => {
741+
it('ViewSchema.form', () => {
742+
const parsed = ViewSchema.parse({ form: { type: 'simple', groups: [S('Account')] } });
743+
expect(has(parsed.form, 'groups')).toBe(false);
744+
expect(parsed.form?.sections?.map((s) => s.label)).toEqual(['Account']);
745+
});
746+
747+
it('ViewSchema.formViews.*', () => {
748+
const parsed = ViewSchema.parse({ formViews: { edit: { type: 'simple', groups: [S('Account')] } } });
749+
expect(has(parsed.formViews?.edit, 'groups')).toBe(false);
750+
expect(parsed.formViews?.edit?.sections?.map((s) => s.label)).toEqual(['Account']);
751+
});
752+
753+
it('defineView (the authored container door)', () => {
754+
const view = defineView({ form: { type: 'simple', groups: [S('Account')] } });
755+
expect(has(view.form, 'groups')).toBe(false);
756+
expect(view.form?.sections?.map((s) => s.label)).toEqual(['Account']);
757+
});
758+
759+
it('defineForm (the metadata-admin form door)', () => {
760+
const form = defineForm({ schemaId: 'report', type: 'simple', groups: [S('Account')] });
761+
expect(has(form, 'groups')).toBe(false);
762+
expect(form.sections?.map((s) => s.label)).toEqual(['Account']);
763+
});
764+
765+
it('ViewItemSchema — the standalone `form` record arm', () => {
766+
const parsed = ViewItemSchema.parse({
767+
name: 'crm_account.edit',
768+
object: 'account',
769+
viewKind: 'form',
770+
config: { type: 'simple', groups: [S('Account')] },
771+
});
772+
const config = (parsed as { config: Record<string, unknown> }).config;
773+
expect(has(config, 'groups')).toBe(false);
774+
expect((config.sections as Array<{ label?: string }>).map((s) => s.label)).toEqual(['Account']);
775+
});
776+
777+
it('ViewMetadataSchema — the container member', () => {
778+
const parsed = ViewMetadataSchema.parse({ form: { type: 'simple', groups: [S('Account')] } }) as {
779+
form?: Record<string, unknown>;
780+
};
781+
expect(has(parsed.form, 'groups')).toBe(false);
782+
expect((parsed.form?.sections as Array<{ label?: string }>).map((s) => s.label)).toEqual(['Account']);
783+
});
784+
785+
it('ViewMetadataSchema — the FLATTENED form-overlay member (`.extend()` inherits the fold)', () => {
786+
const parsed = ViewMetadataSchema.parse({
787+
viewKind: 'form',
788+
type: 'simple',
789+
groups: [S('Account')],
790+
}) as Record<string, unknown>;
791+
expect(has(parsed, 'groups')).toBe(false);
792+
expect((parsed.sections as Array<{ label?: string }>).map((s) => s.label)).toEqual(['Account']);
793+
});
794+
});
795+
});
796+
662797
describe('ViewSchema', () => {
663798
it('should accept minimal view schema', () => {
664799
const view: View = {};

0 commit comments

Comments
 (0)