Skip to content

finding: FormViewSchema.groups is declared as "alias to sections" but nothing folds it — every consumer reads only sections #6926

Closed

Description

@os-project-manager

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.

Activity

  1. claude commented on Aug 9, 2026

    @claude
    Contributor

    Findings round: route only + hold — domain:spec added, finding grade unchanged, no ownership taken.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. os-project-manager commented on Aug 9, 2026

    @os-project-manager
    CollaboratorAuthor

    Triage (maintainer-authorized one-off pass, 2026-08-09, registered on #6015): promoted finding → pm:queue (domain:spec kept), with the standing-policy direction: retire the alias per ADR-0049 — groups is self-labelled "Legacy support", has zero in-repo producers and zero consumers, and a declared-but-unfolded alias is precisely the AI-authoring trap the filing names (validated, then silently ignored). The protocol-17 conversion should fold groups into sections rather than strip, so any in-the-wild author migrates losslessly; tombstone prescription per the #6856 ruling. If implementation discovers real external groups authors beyond what the filing measured, STOP and escalate instead of shipping the removal.


    Generated by Claude Code

  3. self-assigned this
    on Aug 9, 2026
  4. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    Contributor

    CLAIM — PM seat domain:spec (session session_01PiRUoQkTSBBmpyXBY3cVn2), dispatching to an os-dev subagent.


    Generated by Claude Code

  5. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    Contributor

    STOP-and-escalate: the tripwire fired — groups has real consumers, and the fold already exists (in objectui)

    Dev seat for the retirement dispatch. No PR opened. The ruling's stop condition is met, so I am reporting instead of shipping.

    The promotion ruling authorized retirement on this factual basis (quoted verbatim):

    promoted finding → pm:queue (domain:spec kept), with the standing-policy direction: retire the alias per ADR-0049 — groups is self-labelled "Legacy support", has zero in-repo producers and zero consumers, and a declared-but-unfolded alias is precisely the AI-authoring trap the filing names (validated, then silently ignored). The protocol-17 conversion should fold groups into sections rather than strip, so any in-the-wild author migrates losslessly; tombstone prescription per the #6856 ruling. If implementation discovers real external groups authors beyond what the filing measured, STOP and escalate instead of shipping the removal.

    The "zero consumers" half is disproven. The filing's grep covered packages/rest, packages/runtime, packages/metadata-protocol — framework only. It never swept objectui, which is where the fold lives.

    Measured on current origin/main (60be96377) + objectui @ 7b3e048

    The retirement playbook's required liveness-ledger check fails immediately — the framework's own ledger already records this key as live, with named evidence:

    packages/spec/liveness/view.json → .props.form.children.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." }

    Verified independently against real objectui source — five consumer sites, two of which perform the actual fold:

    # Site What it does
    1 packages/react/src/spec-bridge/bridges/form-view.ts:169 const sections = (spec.sections ?? spec.groups ?? []).map(mapSection); — the fold
    2 packages/plugin-form/src/ObjectForm.tsx:129-142 full legacy fold, incl. sub-key mapping title→label, defaultCollapsed→collapsed
    3 packages/plugin-view/src/ObjectView.tsx:875 groups: schema.form?.groups — forwarded deliberately
    4 apps/console/src/components/FormPage.tsx:143 const sections = form.sections ?? form.groups ?? [];
    5 apps/console/src/pages/developer/PublicFormsPage.tsx:112 spec?.groups used as a form-detection signal

    Site 1's own comment states the contract, and site 2's cites the fix that established it:

    // Spec defines `groups` as a legacy alias of `sections`; normalize here so
    // downstream renderers only ever see `sections` (ObjectForm never reads a
    // `groups` key — before this normalization a groups-only spec silently
    // rendered no sections at all, #2545).
    

    Decisive detail: objectui's FormViewSpec interface (form-view.ts:54-80) is a key-for-key structural mirror of FormViewSchema and declares

    /** Legacy alias of `sections` (spec: "Legacy support → alias to sections"). */
    groups?: FormSection[];

    The renderer read the spec's inline comment as a contract and implemented it. The comment is not a lie; it is honored one boundary downstream.

    What this means

    • The issue's core claim — "a form authored with groups: parses clean, translates, and is then read by nobody" — is false. A groups-authored form renders correctly through the primary ObjectUI path.
    • objectui#2545 already shipped the filing's option 1 (Enforce), at the renderer boundary. The ledger names the design: alias-at-one-boundary.
    • Retiring groups would break those five sites and silently break in-the-wild forms that render correctly today. The conversion would migrate sources, but nothing migrates a stored row already rendering fine.

    The real defect, restated (narrower, and still worth fixing)

    The filing did find something true — just not "dead key". The alias is honored at one boundary and not the other:

    So a groups-authored public form renders in the console but degrades on /forms/:slug, /forms/:slug/submit and /forms/:slug/lookup/:field — the three degradations the filing describes. That is an inconsistency between two consumers, not an inert key. Confirmed: the framework performs no groups→sections fold anywhere (grep for sections ?? …groups returns nothing outside objectui).

    cloud: swept, zero form-groups hits. In-repo groups authors: only packages/spec/src/ui/view.test.ts:526,640, both pinning current behavior.

    Recommendation (not mine to decide — direction was ruled, and the ruling's basis has changed)

    Re-triage toward Enforce, i.e. the filing's option 1: add the .transform() to FormViewSchema so the fold happens at the producer, making the declared alias true for every consumer at once — REST included — and letting objectui's five boundary normalizations retire naturally as dead code later. That is contract-first, fixes the REST degradation at its root, and keeps the AI-authoring trap closed without breaking working renders. It does not need the ADR-0087 retirement kit.

    Retirement remains defensible as a later step, but only after the alias is honored uniformly and the objectui reads are removed — and it would need a coordinated cross-repo lap, not this one-PR dispatch.

    ⛔ Restating the filing's own guardrail, which still binds either way: do not fix this with ?? match.form?.groups in rest-server.ts.


    Generated by Claude Code

  6. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    Contributor

    PM status: STOP-and-escalate fired — pm:dispatched → needs-user-decision. No code was shipped; the dispatched dev tore down cleanly.

    The retirement ruling's tripwire ("If implementation discovers real external groups authors … STOP and escalate") triggered on its factual basis, not on scope: the "zero consumers" premise is disproven by measurement — see the dev's evidence comment above (five objectui consumer sites, two performing the actual fold: spec-bridge/bridges/form-view.ts:169, plugin-form/ObjectForm.tsx:129-142; packages/spec/liveness/view.json already records the key live with objectui evidence; objectui#2545 shipped the fold precisely because a groups-only spec once rendered nothing). Retiring would break working renders and in-the-wild stored forms.

    The surviving real defect is narrower: the alias is honored at the objectui boundary but NOT on the framework REST public-form path (rest-server.ts reads sections only at :7298/:7348/:7426/:7570 on current main) — an inconsistency between consumers, not an inert key.

    Awaiting maintainer re-ruling. Options on the table (dev's report, attached above):

    • A — Enforce: producer-side .transform() on FormViewSchema folding groups → sections, making the declared alias true for every consumer at once (REST included); objectui's boundary folds become dead code and retire in a later lap. Dev recommends A; the PM seat concurs.
    • B — Retire as originally ruled: requires a coordinated cross-repo removal of the five objectui reads first, and still breaks stored forms that render today.
    • C — Narrow fix only: leave the alias, fix just the REST gap.

    Also flagged either way: the liveness-ledger note for groups should be refreshed (anchor drift ObjectForm.tsx:90 → :129; the note omits that the framework REST path does not fold — the omission that let the filing read a live key as dead).


    Generated by Claude Code

  7. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    Contributor

    MAINTAINER RE-RULING (2026-08-09, chat) — recorded by the domain:spec PM seat, verbatim and untranslated:

    6926 按 A 方案重裁,直接派

    Option A as escalated (the ruling's referent, from the STOP-and-escalate report above): Enforce — add a .transform() on FormViewSchema folding groups onto sections at 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.

    This supersedes the 2026-08-09 triage direction (retire per ADR-0049), which was correct on the facts it had; the facts changed (five measured objectui consumers, ledger live). needs-user-decision → pm:dispatched.

    RE-CLAIM — PM seat (session session_01PiRUoQkTSBBmpyXBY3cVn2), dispatching to a fresh os-dev subagent.


    Generated by Claude Code

  8. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    Contributor

    PM bookkeeping: pm:dispatched → pm:queue. The A-direction os-dev was stopped externally (mass session-level cancellation, 15:21Z patrol census). ⚠️ Unlike the sibling cancellations, this one left a partial branch: claude/issue-6926-fold-groups-into-sections @ 3a69119 (pushed ~13:45Z, one lap). The next dev should inspect that branch first — reuse it if it holds real fold work, or discard and restart from main; do NOT assume it is complete. The maintainer's A-direction re-ruling (producer .transform() fold) stands unchanged. Claim released; re-dispatch awaits the maintainer's word.


    Generated by Claude Code

  9. os-zhuang commented on Aug 9, 2026

    @os-zhuang
    Contributor

    RE-CLAIM — PM seat (session session_01PiRUoQkTSBBmpyXBY3cVn2), fresh os-dev dispatched under the maintainer's re-acceleration (2026-08-09 chat). Branch claude/issue-6926-fold-groups-into-sections (existing partial 3a69119 — inspect first, reuse or reset per the 15:26Z bookkeeping note), worktree ../objectstack-issue-6926. The A-direction re-ruling and the 13:11Z re-claim scope remain binding.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions