Skip to content

fix(spec): fold FormViewSchema.groups into sections at the producer (#6926) - #7128

Merged
os-zhuang merged 4 commits into
mainfrom
claude/issue-6926-fold-groups-into-sections-r2
Aug 9, 2026
Merged

fix(spec): fold FormViewSchema.groups into sections at the producer (#6926)#7128
os-zhuang merged 4 commits into
mainfrom
claude/issue-6926-fold-groups-into-sections-r2

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #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. This adds it at the producer, so the declared alias becomes true for every consumer of a parsed form at once.

Ruling chain

  1. Original triage (superseded) — promoted the finding to pm:queue with the direction retire the alias per ADR-0049, on the stated basis that groups "has zero in-repo producers and zero consumers".
  2. STOP-and-escalate — that basis was disproven by measurement: five ObjectUI consumer sites, two of which perform the actual fold, and packages/spec/liveness/view.json already recorded the key live. No code shipped.
  3. Maintainer re-ruling (2026-08-09, chat), quoted verbatim and untranslated:

6926 按 A 方案重裁,直接派

Option A as escalated: 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.

What the fold does

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

sections wins when both are present — deliberately the renderer's own primary-path rule (spec.sections ?? spec.groups in spec-bridge/bridges/form-view.ts), so nothing that renders today renders differently. ?? treats an empty array as present, and so does the fold.

⚠️ The two ObjectUI folds disagree on exactly that input, and this pins the primary path's answer: spec-bridge uses ?? (empty sections wins), while plugin-form/ObjectForm.tsx gates on !folded.sections?.length (the alias wins when sections is 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 unchangedgroups stays legal at input, still validated as FormSection[], and a misplaced pane inside it is still reported at groups.0.pane, the path the author actually wrote. Only parsed OUTPUT changes.

.overwrite(), not .transform() (measured)

Both fold identically at parse, but .transform() returns a ZodPipe, and this schema is consumed as an object in two places a pipe breaks: FormViewOverlayWireSchema builds the flattened overlay member with FormViewSchema.extend(...) (a pipe has no .extend), and selectViewMetadataBranch reads ._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:

ctor after .overwrite(): ZodObject   | has .extend: function
ctor after .transform(): ZodPipe     | has .extend: undefined | _zod.def.shape: false

.overwrite() keeps the schema a ZodObject and .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 with git checkout HEAD -- (never git stash).

Test Predicted Measured
groups-only → sections RED RED
both present → sections wins RED RED
empty sections still wins RED RED
empty groups → empty sections RED RED
amended pre-existing groups pin RED RED
7 parse-door cases RED (7) RED (7)
sections-only / neither-key GREEN GREEN
acceptance face unchanged GREEN GREEN
pane reports authored key GREEN GREEN

Predicted 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?.groups in rest-server.ts) is honored. The three /forms/:slug routes resolve through protocol.getMetaItems({ type: 'view' }), which has two sources with different answers:

Source Path Folded?
Code-authored views defineView/defineFormViewSchema.parse/FormViewSchema.parse → registry YES — reaches REST folded
Runtime-saved rows sys_metadataconvertStoredItem (ADR-0087 conversion chain, not a zod parse) NO

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): saveMetaItem already parses a view through ViewMetadataSchema — so with this change it already computes the fold — and then discards parsed.data to 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 table VIEW_FILTER_OPERATOR_ALIASES keeps 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 to groups. 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-resolver groups branch — KEPT, by measurement

resolveMetadataFormLabels is exported from @objectstack/spec/system and takes any form-shaped object. Its one in-repo caller (rest-server.ts, translating getMetaTypes() entries) now feeds it post-parse registry forms, for which the branch is unreachable and a harmless no-op. But a stored sys_metadata body 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.groups refreshed: 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

Gate Result
pnpm lint (ESLint + family gates) clean
tsc --noEmit (spec) TSC_EXIT=0
check:spec-parsed-alias (ADR-0122) OK — 18 self-test assertions
check:export-origins (new today) OK — 4991 exports resolve
check:generated all 11 artifacts up to date
check:generated --reconcile-only GEN2_EXIT=0
check:liveness OK
check:nul-bytes OK — 6564 files, no raw control bytes
check-adr-0087-registration --base origin/main "adds no declared-breaking changeset"
changeset-family self-tests, release-notes, merge-driver OK
gen:schema + gen:docs 1598 schemas, 231 files; one reference delta committed

check:api-surface reported stale before packages/spec was built in this fresh worktree — the gate's own warning says it reads the built dist and 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

  • Branch is ...-r2: the prior dev's partial 3a69119 was 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.
  • One fixture bug was found and fixed in the inherited work: the ViewItemSchema parse-door case used name: 'account_edit', which the schema rejects (dotted snake_case required).
  • Generated artifacts overlap with other in-flight spec devs; not pre-resolved, per the serial-relay protocol.
  • The inferred FormViewParsed type still declares groups? 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 read z.infer< typeof FormViewSchema > verbatim.

Generated by Claude Code

claude added 4 commits August 9, 2026 16:30
…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
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 9, 2026 7:11pm

Request Review

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec.

106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/tenancy-modes.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/permissions/system-context.mdx (via packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/apps.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

7 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants