Found while implementing #6601 (which narrows the public-form schema to the fields sections declares — that made me check what else can declare fields on a form). Filed, not fixed: the fix belongs at the producer (packages/spec), which is outside #6601's lane, and #6601's dispatch explicitly forbade touching packages/spec.
Graded observation-class (finding, no pm:queue): no shipped app authors groups today, so nothing a user hits right now. The hazard is authoring-time, and specifically AI-authoring-time.
What was measured
packages/spec/src/ui/view.zod.ts:1673-1674 declares both keys on FormViewSchema:
sections: z.array(FormSectionSchema).optional(), // For simple layout
groups: z.array(FormSectionSchema).optional(), // Legacy support -> alias to sections
The comment promises an alias. Nothing performs the fold — FormViewSchema's .superRefine at :1821 iterates both arrays for the section.pane check (so the key is validated), and there is no .transform() that moves groups onto sections. Grepping the framework for a consumer of form.groups:
packages/rest/src/rest-server.ts — all three public-form routes read match.form?.sections only (schema narrowing at :7126, submit whitelist at :7236, lookup fieldCfg scan at :7333).
packages/runtime, packages/metadata-protocol — no .groups read on a form at all (the .groups hits there are navigation trees and book.groups, different surfaces).
packages/spec/src/system/i18n-resolver.ts:1802 is the one place that handles it, and only for translation (next.groups = form.groups.map(translateSection)), which is what makes the key look alive from a grep.
So a form authored with groups: parses clean, translates, and is then read by nobody.
Why it matters
This is the "accepted but ignored" shape this repo rejects on principle — the same argument that retired form.aria in the #3896 audit close-out ("an ACCESSIBILITY claim that is merely accepted is false compliance"). It is worse than a plain inert key because the inline comment actively tells an author the key works: an agent authoring metadata reads // Legacy support -> alias to sections, writes groups, gets a green parse, and ships a form that renders nothing.
On the public-form surface specifically it degrades into three separate silent failures at once, which is how I found it. For a groups-authored public form:
Suggested disposition
Contract-first: fix at the producer, not by teaching each consumer a second key to read. Two options, in the ADR-0049 enforce-or-remove frame:
- Enforce — fold
groups onto sections in a .transform() on FormViewSchema so the declared alias becomes true for every consumer at once (this is what aliases: already does for scalar renames elsewhere in the file; a whole-array alias just needs the transform). Cheapest way to make the comment honest.
- Remove — retire
groups via the ADR-0087 registry with a retiredKey message pointing at sections, the way form.aria went. Defensible if the liveness ledger says no authored metadata uses it, which is what my grep suggests.
I have no evidence of business pull for groups as an authoring spelling, so removal looks like the smaller surface — but the ledger check and the ADR-0087 conversion are the retirement playbook's, not mine to assert from a grep.
⛔ Whichever is chosen, do NOT fix this by adding ?? match.form?.groups in rest-server.ts. A lenient consumer is where AI-authored metadata errors hide, and it would leave the other consumers still blind.
Related: #6601 (found during), #6920 (the submit-side degeneration this compounds), #3896 (the form.aria retirement precedent), ADR-0049, ADR-0087.
Found while implementing #6601 (which narrows the public-form schema to the fields
sectionsdeclares — that made me check what else can declare fields on a form). Filed, not fixed: the fix belongs at the producer (packages/spec), which is outside #6601's lane, and #6601's dispatch explicitly forbade touchingpackages/spec.Graded observation-class (
finding, nopm:queue): no shipped app authorsgroupstoday, so nothing a user hits right now. The hazard is authoring-time, and specifically AI-authoring-time.What was measured
packages/spec/src/ui/view.zod.ts:1673-1674declares both keys onFormViewSchema:The comment promises an alias. Nothing performs the fold —
FormViewSchema's.superRefineat :1821 iterates both arrays for thesection.panecheck (so the key is validated), and there is no.transform()that movesgroupsontosections. Grepping the framework for a consumer ofform.groups:packages/rest/src/rest-server.ts— all three public-form routes readmatch.form?.sectionsonly (schema narrowing at :7126, submit whitelist at :7236, lookupfieldCfgscan at :7333).packages/runtime,packages/metadata-protocol— no.groupsread on a form at all (the.groupshits there are navigation trees andbook.groups, different surfaces).packages/spec/src/system/i18n-resolver.ts:1802is the one place that handles it, and only for translation (next.groups = form.groups.map(translateSection)), which is what makes the key look alive from a grep.So a form authored with
groups:parses clean, translates, and is then read by nobody.Why it matters
This is the "accepted but ignored" shape this repo rejects on principle — the same argument that retired
form.ariain the #3896 audit close-out ("an ACCESSIBILITY claim that is merely accepted is false compliance"). It is worse than a plain inert key because the inline comment actively tells an author the key works: an agent authoring metadata reads// Legacy support -> alias to sections, writesgroups, gets a green parse, and ships a form that renders nothing.On the public-form surface specifically it degrades into three separate silent failures at once, which is how I found it. For a
groups-authored public form:GET /forms/:slugpublishes an empty schema after finding: a section-less public form publishes EVERY field of its target object to anonymous callers #6601 (before finding: a section-less public form publishes EVERY field of its target object to anonymous callers #6601 it published the whole object, i.e. it hit exactly the bug finding: a section-less public form publishes EVERY field of its target object to anonymous callers #6601 fixes);POST /forms/:slug/submithas an emptyallowedFieldsand therefore accepts every non-anchor key (see finding: a section-less public form accepts ANY non-anchor field from an anonymous visitor (the write-side twin of #6601) #6920);GET /forms/:slug/lookup/:fieldfinds nofieldCfgand answers403 LOOKUP_NOT_PUBLICfor every field.Suggested disposition
Contract-first: fix at the producer, not by teaching each consumer a second key to read. Two options, in the ADR-0049 enforce-or-remove frame:
groupsontosectionsin a.transform()onFormViewSchemaso the declared alias becomes true for every consumer at once (this is whataliases:already does for scalar renames elsewhere in the file; a whole-array alias just needs the transform). Cheapest way to make the comment honest.groupsvia the ADR-0087 registry with aretiredKeymessage pointing atsections, the wayform.ariawent. Defensible if the liveness ledger says no authored metadata uses it, which is what my grep suggests.I have no evidence of business pull for
groupsas an authoring spelling, so removal looks like the smaller surface — but the ledger check and the ADR-0087 conversion are the retirement playbook's, not mine to assert from a grep.⛔ Whichever is chosen, do NOT fix this by adding
?? match.form?.groupsinrest-server.ts. A lenient consumer is where AI-authored metadata errors hide, and it would leave the other consumers still blind.Related: #6601 (found during), #6920 (the submit-side degeneration this compounds), #3896 (the
form.ariaretirement precedent), ADR-0049, ADR-0087.