Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .changeset/form-groups-alias-folded-at-producer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
"@objectstack/spec": minor
---

fix(spec): `form.groups` is folded onto `form.sections` at the producer — the declared alias is now true for every consumer (#6926)

`FormViewSchema` has declared `groups` with the inline comment *"Legacy support
-> alias to sections"* for as long as it has existed, and nothing in this repo
performed the fold. The alias was honored exactly **one boundary downstream**,
inside the renderer (ObjectUI's `spec-bridge` reads `spec.sections ??
spec.groups`, and `plugin-form/ObjectForm` carries a full legacy fold — shipped
because a `groups`-only spec once rendered nothing at all). Every framework
consumer that is not that renderer read `sections` only.

So one authored form behaved two ways. A `groups`-authored **public** form
rendered correctly in the console and degraded on all three REST public-form
routes at once, because each of them walks `sections`:

- `GET /forms/:slug` published an empty field schema,
- `POST /forms/:slug/submit` computed an empty `allowedFields` whitelist,
- `GET /forms/:slug/lookup/:field` answered `403 LOOKUP_NOT_PUBLIC` for every
field.

The fix is at the producer, not in the consumers: `FormViewSchema` now folds
`groups` onto `sections` at parse, so every consumer of a parsed form sees one
key. Teaching each consumer a second key to read was the other option and was
rejected — a lenient consumer is where authored (especially AI-authored)
metadata errors hide, and it leaves the next consumer blind.

**What changes.** Only the parsed OUTPUT of a form view:

| Authored | Parsed before | Parsed now |
| --- | --- | --- |
| `groups: [...]` | `groups: [...]`, no `sections` | `sections: [...]`, no `groups` |
| both keys | both, verbatim | `sections` (the authored one), no `groups` |
| `sections: [...]` | unchanged | unchanged |

`sections` wins when both are present — deliberately the renderer's own
`sections ?? groups` rule, so nothing that renders today renders differently.
`??` treats an empty array as present, and so does the fold.

**What does not change.** The acceptance face: `groups` is still a legal
authoring key, still validated as `FormSection[]`, and a misplaced `pane` inside
it is still reported at `groups.0.pane` — the path the author actually wrote.
Consumers that read metadata *before* it is parsed are unaffected and still read
both keys, which is correct for them: `os lint`'s view rules walk authored
sources, and a `sys_metadata` row is persisted verbatim and re-read through the
ADR-0087 stored-row conversion chain rather than through a Zod parse. The fold
narrows output; it never narrows what is accepted.

The fold is declared once and inherited by every parse door — `FormViewSchema`
itself, `ViewSchema.form` and `.formViews.*`, both `ViewItemSchema` form arms,
and `ViewMetadataSchema`'s container and flattened form-overlay members.

If you read `.groups` off a **parsed** form view, read `.sections` instead; it
now carries what `groups` used to.
2 changes: 1 addition & 1 deletion content/docs/references/ui/view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ Column footer summary configuration
| **modalSize** | `Enum<'sm' \| 'default' \| 'lg' \| 'xl' \| 'full'>` | optional | Modal size (modal forms) |
| **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) |
| **sections** | `{ name?: string; label?: string \| Record<string, string>; description?: string; collapsible?: boolean; … }[]` | optional | |
| **groups** | `{ name?: string; label?: string \| Record<string, string>; description?: string; collapsible?: boolean; … }[]` | optional | |
| **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`. |
| **subforms** | `{ childObject: string; relationshipField?: string; columns?: any[]; amountField?: string; … }[]` | optional | Inline master-detail child collections |
| **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. |
| **sharing** | `{ enabled?: boolean; publicLink?: string; password?: string; allowedDomains?: string[]; … }` | optional | Public sharing configuration for this form |
Expand Down
5 changes: 4 additions & 1 deletion packages/spec/liveness/view.json
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,10 @@
},
"groups": {
"status": "live",
"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."
"verifiedAt": "2026-08-09",
"evidenceScope": "cross-repo",
"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)",
"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."
},
"subforms": {
"status": "live",
Expand Down
13 changes: 13 additions & 0 deletions packages/spec/src/system/i18n-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1815,6 +1815,19 @@ export function resolveMetadataFormLabels<T extends Record<string, any>>(
next.sections = form.sections.map(translateSection);
}
// Legacy alias — some forms use `groups` instead of `sections`.
//
// KEPT after #6926 folded `groups` onto `sections` at the producer, and the
// measurement is why. This helper takes ANY form-shaped object (`T extends
// Record<string, any>`), and it is exported: its one in-repo caller
// (`rest-server.ts` translating `getMetaTypes()` entries) now feeds it
// post-parse forms from `METADATA_FORM_REGISTRY`, all of them `defineForm`
// outputs, so for THAT caller the branch is unreachable — but a stored
// `sys_metadata` body still carries the authored key (`saveMetaItem` keeps
// the body verbatim and the read replays the ADR-0087 conversion chain, not
// a zod parse), so a caller handing this a pre-parse form is not a
// hypothetical. Deleting the branch would silently drop translations for
// exactly those forms — the same "measured one consumer, missed the other"
// mistake #6926 was filed for. It retires when the stored shape folds too.
if (Array.isArray(form.groups)) {
next.groups = form.groups.map(translateSection);
}
Expand Down
135 changes: 135 additions & 0 deletions packages/spec/src/ui/view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ import {
type ViewData,
type HttpRequest,
defineView,
defineForm,
ViewItemSchema,
ViewMetadataSchema,
} from './view.zod';

import {
Expand Down Expand Up @@ -538,6 +541,12 @@ describe('FormViewSchema', () => {
};

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

it('should accept tabbed form view', () => {
Expand Down Expand Up @@ -659,6 +668,132 @@ describe('FormViewSchema', () => {
});
});

/**
* #6926 — `groups` is declared as an alias of `sections` and, until this
* change, nothing folded it: the alias was honored one boundary downstream in
* objectui's renderer, so the same authored form rendered in the console and
* degraded on the framework's REST public-form routes (which read `sections`
* only). The fold now happens at the PRODUCER.
*
* These cases discriminate the fold — the two pre-existing `groups` pins
* (acceptance, and the `pane` error path) cannot, which is the measured reason
* an unfolded alias survived this file for its whole life.
*/
describe('FormViewSchema — the `groups` legacy alias folds onto `sections` (#6926)', () => {
const S = (label: string) => ({ label, fields: [label.toLowerCase()] });
const has = (o: unknown, k: string) => Object.prototype.hasOwnProperty.call(o as object, k);

it('groups-only → sections, with `groups` absent from the output', () => {
const parsed = FormViewSchema.parse({ type: 'simple', groups: [S('Account')] });
expect(has(parsed, 'groups')).toBe(false);
expect(parsed.sections?.map((s) => s.label)).toEqual(['Account']);
});

it('both present → `sections` wins (the renderer\'s own `sections ?? groups` rule)', () => {
const parsed = FormViewSchema.parse({
type: 'simple',
sections: [S('Canonical')],
groups: [S('Legacy')],
});
expect(has(parsed, 'groups')).toBe(false);
expect(parsed.sections?.map((s) => s.label)).toEqual(['Canonical']);
});

it('an EMPTY `sections` still wins over a populated `groups` (`??`, not a merge)', () => {
const parsed = FormViewSchema.parse({ type: 'simple', sections: [], groups: [S('Legacy')] });
expect(has(parsed, 'groups')).toBe(false);
expect(parsed.sections).toEqual([]);
});

it('an empty `groups` folds to an empty `sections` (content, not absence)', () => {
const parsed = FormViewSchema.parse({ type: 'simple', groups: [] });
expect(has(parsed, 'groups')).toBe(false);
expect(parsed.sections).toEqual([]);
});

it('sections-only and neither-key forms are untouched', () => {
expect(FormViewSchema.parse({ type: 'simple', sections: [S('Only')] }).sections?.[0]?.label)
.toBe('Only');
const bare = FormViewSchema.parse({ type: 'simple' });
expect(has(bare, 'sections')).toBe(false);
expect(has(bare, 'groups')).toBe(false);
});

it('the ACCEPTANCE face is unchanged — `groups` stays legal at input', () => {
expect(FormViewSchema.safeParse({ type: 'simple', groups: [S('Account')] }).success).toBe(true);
// …and the fold does not launder an invalid section past the schema.
expect(FormViewSchema.safeParse({ type: 'simple', groups: [{ label: 'No fields' }] }).success)
.toBe(false);
});

it('the `pane` refinement still reports the key the AUTHOR wrote', () => {
const result = FormViewSchema.safeParse({
type: 'simple',
groups: [{ label: 'Account', pane: 'primary', fields: ['account_name'] }],
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((i) => i.path.join('.') === 'groups.0.pane')).toBe(true);
}
});

describe('every parse door inherits the fold (one declaration, not per-door)', () => {
it('ViewSchema.form', () => {
const parsed = ViewSchema.parse({ form: { type: 'simple', groups: [S('Account')] } });
expect(has(parsed.form, 'groups')).toBe(false);
expect(parsed.form?.sections?.map((s) => s.label)).toEqual(['Account']);
});

it('ViewSchema.formViews.*', () => {
const parsed = ViewSchema.parse({ formViews: { edit: { type: 'simple', groups: [S('Account')] } } });
expect(has(parsed.formViews?.edit, 'groups')).toBe(false);
expect(parsed.formViews?.edit?.sections?.map((s) => s.label)).toEqual(['Account']);
});

it('defineView (the authored container door)', () => {
const view = defineView({ form: { type: 'simple', groups: [S('Account')] } });
expect(has(view.form, 'groups')).toBe(false);
expect(view.form?.sections?.map((s) => s.label)).toEqual(['Account']);
});

it('defineForm (the metadata-admin form door)', () => {
const form = defineForm({ schemaId: 'report', type: 'simple', groups: [S('Account')] });
expect(has(form, 'groups')).toBe(false);
expect(form.sections?.map((s) => s.label)).toEqual(['Account']);
});

it('ViewItemSchema — the standalone `form` record arm', () => {
const parsed = ViewItemSchema.parse({
name: 'crm_account.edit',
object: 'account',
viewKind: 'form',
config: { type: 'simple', groups: [S('Account')] },
});
const config = (parsed as { config: Record<string, unknown> }).config;
expect(has(config, 'groups')).toBe(false);
expect((config.sections as Array<{ label?: string }>).map((s) => s.label)).toEqual(['Account']);
});

it('ViewMetadataSchema — the container member', () => {
const parsed = ViewMetadataSchema.parse({ form: { type: 'simple', groups: [S('Account')] } }) as {
form?: Record<string, unknown>;
};
expect(has(parsed.form, 'groups')).toBe(false);
expect((parsed.form?.sections as Array<{ label?: string }>).map((s) => s.label)).toEqual(['Account']);
});

it('ViewMetadataSchema — the FLATTENED form-overlay member (`.extend()` inherits the fold)', () => {
const parsed = ViewMetadataSchema.parse({
viewKind: 'form',
type: 'simple',
groups: [S('Account')],
}) as Record<string, unknown>;
expect(has(parsed, 'groups')).toBe(false);
expect((parsed.sections as Array<{ label?: string }>).map((s) => s.label)).toEqual(['Account']);
});
});
});

describe('ViewSchema', () => {
it('should accept minimal view schema', () => {
const view: View = {};
Expand Down
Loading
Loading