Skip to content

Commit b01bdbc

Browse files
docs(spec): describe the form-section collapse pair on both keys (#19736)
Fixes #19311 Clause-②: no Why that reading: two `.describe()` strings and one TSDoc block. No key is added, removed or re-typed, no closed set gains a member, no export moves and no refinement changes. `check:authorable-surface` and `check:api-surface` are both green with no delta, and the wizard-step and `group` co-declaration refusals parse identically before and after. Grade: `patch`, measured below rather than pattern-matched. ## The defect, re-derived on `origin/main` rather than taken on trust `FormSectionSchema` declares two authorable booleans with **no `.describe()` at all**, between neighbours that have one: ``` packages/spec/src/ui/view.zod.ts:3315 collapsible: z.boolean().default(false), packages/spec/src/ui/view.zod.ts:3316 collapsed: z.boolean().default(false), ``` **Lit control, same file, same instrument:** `view.zod.ts:1054` reads `collapsed: z.boolean().default(false).describe('Collapse groups by default (presentation only)')` — a different schema (group-by presentation). So a describe on a key of this name IS visible to the grep, and the zero at `:3315–3316` is a reading, not a blind spot. **Dark control:** `git grep -n "collapsedd"` over the same file → 0 hits, exit 1. Downstream of that silence, the generated page printed two empty Description cells (`content/docs/references/ui/view.mdx:334-335`, and again at `:466-467` and `:482-483`, the three places that page projects this schema). **This is NOT the surface the first increment landed on.** PR #19699 (merged `fa29803417`) corrected `packages/spec/src/data/object.zod.ts:1220` — the `ObjectFieldGroup` pair, a different schema in a different file, which is why it merged as `Part of`. Both keys of **this** pair are described here, not just `collapsed`: describing one of a silent pair recreates the ambiguity one key over. ## Probe — every sentence measured against the BUILT package Built with `pnpm --filter @objectstack/spec build`, then probed through the module the `exports` map resolves (`require.resolve('@objectstack/spec/ui')` → `packages/spec/dist/ui/index.js`, printed in the same run). ### A. `FormSectionSchema.safeParse`, one section, all four combinations | authored | `collapsible` out | `collapsed` out | | :--- | :--- | :--- | | neither — **CONTROL (lit)**: default arm must fire | `false` | `false` | | `collapsible: true` | `true` | `false` | | `collapsible: false` | `false` | `false` | | `collapsed: true` | `false` | `true` | | `collapsed: false` | `false` | `false` | | both `true` | `true` | `true` | | `collapsed: true` + `collapsible: false` | `false` | `true` | | `collapsed: false` + `collapsible: true` | `true` | `false` | | both `false` | `false` | `false` | | `collapsable: true` — **CONTROL (dark)** | REFUSED `unrecognized_keys` | — | | `collapsed: 'true'` — **CONTROL (dark)** | REFUSED `invalid_type@collapsed` | — | Both lit and both dark controls answered as predicted, so the nine readings are measurements. ### B. The normalizer question the dispatch asked — answered NO There is no normalizer, no parse wrapper and no fold between these two keys. `{ collapsed: true }` parses to `{ collapsible: false, collapsed: true }`, verbatim, with both keys present in the output (`.default(false)`), and `safeParseAsync` returns the same. The section's only `.transform` is `normalizeVisibleWhen`, which touches `visibleOn` and nothing else. ⇒ That is the **precision limit**, and it is stated in the text rather than left implied: the implication `collapsed` ⇒ `collapsible` is a **renderer** rule applied from the declaration, never a parse-time rewrite. The describes say so explicitly, and say that the parsed `collapsible` must never be read as "a disclosure control renders". This is the exact opposite of the `ObjectFieldGroup` pair, where a real parse-time mapping folds the booleans onto the ADR-0085 `collapse` enum — the two surfaces share key names and share nothing else, so the describes name that difference. ### C. The two refusals the describes claim | input | wizard form | beside `group` | | :--- | :--- | :--- | | `collapsed: true` | REFUSED at `sections.0.collapsed` | REFUSED at `collapsed` | | `collapsible: true` | REFUSED at `sections.0.collapsible` | REFUSED at `collapsible` | | `collapsed: false` | ACCEPTED | ACCEPTED | | `collapsible: false` | ACCEPTED | ACCEPTED | | neither — **CONTROL (lit)** | ACCEPTED | ACCEPTED | | `simple` form + `collapsed: true` — **CONTROL (lit)** | ACCEPTED (the refusal is wizard-only) | — | | `group` + `fields` — **CONTROL (dark)** | — | REFUSED at `group` | ⚠️ The first run of this block had its two lit controls come out REFUSED. That was a malformed fixture on my side (`FormViewSchema` is the form CONFIG and carries no `name` / `label` / `objectName`), not a reading — so those rows were discarded and the block re-run after the fixture was repaired. The table above is the repaired run. Recorded because the controls are the only reason the first, wrong version did not ship as a finding. ### D. Reverse verification — the gate can see this change After the describe edit and a spec rebuild, `pnpm --filter @objectstack/spec check:generated` exits **1** and names exactly one stale artifact — `content/docs/references/**` — while the other 14 stay green. **Direction observed = turns red, as predicted.** After `gen:schema && gen:docs` the same aggregate exits 0. That is what makes the final green a measurement rather than a silence. ## Downstream consumer, read at the PINNED sha Read at `.objectui-sha` = **`87af769e9a3ee28ace099fdd653d3ebd79fe82e2`** (not objectui's head, which is `0cf2d66`); `git merge-base --is-ancestor` on the pin exits 0, which is self-proving. - `packages/plugin-form/src/ObjectForm.tsx:1560` applies the ruling: `Boolean(section.collapsible) || Boolean(section.collapsed)`, with a block comment that states it is read from the DECLARATION and never from live collapse state, and that letters B (refuse the combination) and C (a dev-only warning) were both refused. The describes follow that, and ⛔ neither adds a refinement nor asks for a lint rule. - `packages/plugin-form/src/DrawerForm.tsx:609` and `:677` still push `collapsible: section.collapsible` **alone** on both drawer arms. That file's own comment declares the gap deliberate and fenced, and says converging it *is* the `collapsed` / `collapsible` decision. **So the contract this PR publishes is the thing that arm was waiting on** — it is a sibling-repo residual, another seat's card, and nothing here touches it. Reported, not ridden along. - In **this** repo nothing consumes the raw pair: `git grep` for a `.collapsible` / `.collapsed` member access across `packages/**` returns only the `ObjectFieldGroup` mapping and test files; `packages/lint/src` has **0** hits for `collapsible`, with a positive control (`form-section-group-unknown`, 2 files) firing on the same command and scope. - All 22 first-party form producers under `packages/spec/src/**/*.form.ts` pair `collapsible: true` with `collapsed: true`, so **zero** in-repo producers exercise the trap. It is an author-facing trap, not a live in-repo defect — which is precisely why the remedy is declaration text. ## Generated artifacts — the four-step sequence, run mechanically `content/docs/references/**` is routed `merge=os-regen`, the class that merges with exit 0 and zero conflict markers while silently dropping one side. 1. `bash scripts/pm/os-regen-merge.sh` — steps 1–3, exit 0. It merged `origin/main` (`e37ea4d060`), took main's side of the generated artifacts main moved, and committed the merge **before** any regeneration. ⛔ No rebase, ⛔ no force-push, ⛔ no `git stash`, ⛔ no hand merge of a routed path. 2. `gen:schema` was run only **after** `MERGE_HEAD` was gone and the tree was clean, so the authorable-surface anchor could not roll back to the old fork point. `packages/spec/authorable-surface.base.json` is byte-identical to `origin/main`. 3. Regenerated `pnpm --filter @objectstack/spec build && gen:schema && gen:docs`. **Exactly one** file moved, and by exactly the rows this one change derives: 6 rows in `content/docs/references/ui/view.mdx` — the pair, three times, because that page projects `FormSectionSchema` three times. `git diff --name-only origin/main -- content/docs/references/` names that file and no other. 4. Inspected the **staged** diff (`git diff --cached`), not the working-tree diff, before committing. ### Survival assertion for the in-flight sibling, PR #19618 #19618 (open, draft, head `7cc0ca1b3d`) touches four `content/docs/references/**` pages. It is **unmerged**, so what my regeneration could destroy is main's copy of those pages. Quoted-exact `git grep -F`, counts identical on my tree and `origin/main`: | needle | path | mine | `origin/main` | | :--- | :--- | :--- | :--- | | the pre-#19618 `tenancy` type spelling carrying `organizationField?` | `api/metadata.mdx` | 1 | 1 | | same | `data/object.mdx` | 1 | 1 | | same | `system/migration.mdx` | 2 | 2 | | #19618's post-change `tenancy` spelling | all four | 0 | 0 (it has not landed) | | `STAMP-ONLY: column carrying the organization a row is ABOUT` (its deletion target) | `data/object.mdx` | 2 | 2 | | **CONTROL (lit)** `Multi-tenancy configuration for SaaS applications` | all four | 1 / 1 / 2 | fires | | **CONTROL (dark)** a near-miss of the same spelling | all four | 0, exit 1 | discriminates | All four of its reference pages are **byte-identical** to `origin/main` in my tree (`git hash-object` vs `git rev-parse origin/main:PATH`, four matching pairs). Every os-regen-routed path outside `content/docs/references/ui/view.mdx` differs from `origin/main` by **zero** files. Also asserted, because the merge driver swallows implementation bodies and not only index entries: the **previous increment's** body survives — `git grep -cF "true → 'collapsed' (collapsible, starts closed) on its own"` reads 1 in `packages/spec/src/data/object.zod.ts` and 2 in `content/docs/references/data/object.mdx`, identical on my tree and `origin/main`, with a near-miss dark control at 0 / exit 1. ## Changeset — measured, not pattern-matched `npm pack --dry-run --json` in `packages/spec` (exit 0, 2031 files): | file | verdict | | :--- | :--- | | `src/ui/view.zod.ts` — **the edited file** | SHIPS | | `package.json` — **positive control** | SHIPS | | `src/data/object.zod.ts` — **positive control** | SHIPS | | `src/ui/view.test.ts` — **negative control** | does NOT ship | | `scripts/build-docs.ts` — **negative control** | does NOT ship | | `tsconfig.json` — **negative control** | does NOT ship | Scanning the tarball's own file list for the new describe text: **42 shipped files carry it** — 30 under `dist/`, 11 under `json-schema/`, 1 under `src/`. Negative control with a lit leg: the string `are GENERATED — do not hand-edit them` is present in `packages/spec/scripts/lib/generated-output.ts` and in **0** shipped files, because that file does not ship. ⇒ The bytes publish, so `skip-changeset` would be a false declaration. A `patch` changeset is in the diff. ## Acceptance notes — found, ⛔ not fixed here - `packages/spec/src/ui/view.zod.ts:3274` and `:5951` describe the pair in prose and in a doc example without the dependency; neither is falsified by this change and neither is a trap as written (the `:5951` example writes both keys). Noted, not filed. - The `sections` / `groups` rows on the generated page print a truncated inline type (`… collapsible?: boolean; …`) with no Description at all. That is the generator's type-column truncation, not this pair, and it is unchanged here. - `packages/lint` carries no rule on this pair (0 hits, positive control firing). Consistent with the 2026-09-18 ruling, which refused letter C — a dev-only warning — so this is a deliberate absence, ⛔ not a gap to fill. --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent af4f8ee commit b01bdbc

3 files changed

Lines changed: 78 additions & 8 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
`FormSection.collapsible` / `FormSection.collapsed` — both keys now carry a `.describe()`, so the published reference page no longer prints two empty Description cells for two authorable booleans (#19311).
6+
7+
They were the only keys in `FormSectionSchema` with no contract text at all, sitting between neighbours that have it, and the dependency between them was published nowhere. **Both** are described rather than only `collapsed`: the sibling silence is what made the gap ambiguous in the first place, and describing one of a pair recreates it one key over.
8+
9+
Measured against the built package, `FormSectionSchema.safeParse` on one section:
10+
11+
| authored on the section | `collapsible` after parse | `collapsed` after parse |
12+
| :--- | :--- | :--- |
13+
| neither | `false` | `false` |
14+
| `collapsible: true` | `true` | `false` |
15+
| `collapsed: true` | `false` | `true` |
16+
| `collapsed: true` + `collapsible: false` | `false` | `true` |
17+
| both `true` | `true` | `true` |
18+
| both `false` | `false` | `false` |
19+
20+
- **Parse does NOT normalize the pair, in either direction.** `{ collapsed: true }` parses to `{ collapsible: false, collapsed: true }` verbatim, and `safeParseAsync` agrees. So the implication `collapsed` ⇒ `collapsible` — ruled 2026-09-18, letter A — is a **renderer** rule applied from the declaration, and the describes say exactly that rather than implying a fold the schema does not perform. A consumer reading the parsed `collapsible` is reading what the author typed, never whether a disclosure control renders.
21+
- **This is the opposite of the `ObjectFieldGroup` pair**, where a parse-time mapping really does fold the old booleans onto the ADR-0085 `collapse` enum. The two surfaces share key names and share nothing else; the describes say so.
22+
- **Text only.** No key is added, removed or re-typed, no accept set moves and no refinement changes: `check:authorable-surface` and `check:api-surface` are both green with no delta, and the wizard-step and `group` co-declaration refusals parse identically before and after (only `true` is refused in either place; `false` is accepted in both).

‎content/docs/references/ui/view.mdx‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -331,8 +331,8 @@ View filter rule
331331
| **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) |
332332
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
333333
| **description** | `string` | optional | Optional description rendered under the section header. |
334-
| **collapsible** | `boolean` | optional (default: `false`) | |
335-
| **collapsed** | `boolean` | optional (default: `false`) | |
334+
| **collapsible** | `boolean` | optional (default: `false`) | Whether the section renders a disclosure control, so a reader can close it and open it again. Default `false`: a section declaring neither collapse key is always open and shows no control. ⚠️ `collapsed: true` IMPLIES this key — an explicit `collapsible: false` beside it does NOT take the control away (ruled 2026-09-18). The renderer resolves that from the DECLARATION; parse never rewrites the pair, so a parsed section still reports the `false` that was authored. Only `true` is refused on a wizard step and beside `group`; `false` is accepted in both, because it declares exactly what those surfaces already deliver. |
335+
| **collapsed** | `boolean` | optional (default: `false`) | Whether the section starts closed. Default `false`. ⚠️ `collapsed: true` IMPLIES `collapsible` and is sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that reopens it, and it outranks an explicit `collapsible: false` (ruled 2026-09-18; refusing the combination at the declaration, and warning on it, were both rejected — nobody can depend on a section that cannot be opened). The implication is a renderer rule, never a parse-time rewrite: `{ collapsed: true }` still parses to `collapsible: false, collapsed: true`, so the parsed `collapsible` must never be read as "a control renders". Only `true` is refused on a wizard step (steps do not collapse) and beside `group`, whose field group declares the pair. |
336336
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): unbound on the standalone form routes, where the predicate would fault open. |
337337
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
338338
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
@@ -463,8 +463,8 @@ Form-view select option — the object-field option shape minus the per-option `
463463
| **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) |
464464
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
465465
| **description** | `string` | optional | Optional description rendered under the section header. |
466-
| **collapsible** | `boolean` | optional (default: `false`) | |
467-
| **collapsed** | `boolean` | optional (default: `false`) | |
466+
| **collapsible** | `boolean` | optional (default: `false`) | Whether the section renders a disclosure control, so a reader can close it and open it again. Default `false`: a section declaring neither collapse key is always open and shows no control. ⚠️ `collapsed: true` IMPLIES this key — an explicit `collapsible: false` beside it does NOT take the control away (ruled 2026-09-18). The renderer resolves that from the DECLARATION; parse never rewrites the pair, so a parsed section still reports the `false` that was authored. Only `true` is refused on a wizard step and beside `group`; `false` is accepted in both, because it declares exactly what those surfaces already deliver. |
467+
| **collapsed** | `boolean` | optional (default: `false`) | Whether the section starts closed. Default `false`. ⚠️ `collapsed: true` IMPLIES `collapsible` and is sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that reopens it, and it outranks an explicit `collapsible: false` (ruled 2026-09-18; refusing the combination at the declaration, and warning on it, were both rejected — nobody can depend on a section that cannot be opened). The implication is a renderer rule, never a parse-time rewrite: `{ collapsed: true }` still parses to `collapsible: false, collapsed: true`, so the parsed `collapsible` must never be read as "a control renders". Only `true` is refused on a wizard step (steps do not collapse) and beside `group`, whose field group declares the pair. |
468468
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): unbound on the standalone form routes, where the predicate would fault open. |
469469
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
470470
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
@@ -479,8 +479,8 @@ Form-view select option — the object-field option shape minus the per-option `
479479
| **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) |
480480
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
481481
| **description** | `string` | optional | Optional description rendered under the section header. |
482-
| **collapsible** | `boolean` | optional (default: `false`) | |
483-
| **collapsed** | `boolean` | optional (default: `false`) | |
482+
| **collapsible** | `boolean` | optional (default: `false`) | Whether the section renders a disclosure control, so a reader can close it and open it again. Default `false`: a section declaring neither collapse key is always open and shows no control. ⚠️ `collapsed: true` IMPLIES this key — an explicit `collapsible: false` beside it does NOT take the control away (ruled 2026-09-18). The renderer resolves that from the DECLARATION; parse never rewrites the pair, so a parsed section still reports the `false` that was authored. Only `true` is refused on a wizard step and beside `group`; `false` is accepted in both, because it declares exactly what those surfaces already deliver. |
483+
| **collapsed** | `boolean` | optional (default: `false`) | Whether the section starts closed. Default `false`. ⚠️ `collapsed: true` IMPLIES `collapsible` and is sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that reopens it, and it outranks an explicit `collapsible: false` (ruled 2026-09-18; refusing the combination at the declaration, and warning on it, were both rejected — nobody can depend on a section that cannot be opened). The implication is a renderer rule, never a parse-time rewrite: `{ collapsed: true }` still parses to `collapsible: false, collapsed: true`, so the parsed `collapsible` must never be read as "a control renders". Only `true` is refused on a wizard step (steps do not collapse) and beside `group`, whose field group declares the pair. |
484484
| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record` (+ `previous`, `parent`) in runtime forms, or `data` in metadata forms. `current_user` (and the ADR-0068 aliases `user` / `ctx.user` / `os.user`) resolves here too — CLIENT-SIDE only: nothing server-side evaluates a form-view section `visibleWhen`, so a role test here hides the controls and protects no data (declare permission-set field-level security for that), and on the public `/f/:slug` route no host publishes a scope, so the root is unbound and the predicate faults open. No `features.*` on ANY form-view predicate — refused at parse (ruled 2026-08-27): unbound on the standalone form routes, where the predicate would fault open. |
485485
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
486486
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |

‎packages/spec/src/ui/view.zod.ts‎

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3312,8 +3312,56 @@ export const FormSectionSchema = lazySchema(() => strictObject({
33123312
name: z.string().optional().describe('Stable section identifier for i18n lookup (snake_case)'),
33133313
label: I18nLabelSchema.optional(),
33143314
description: z.string().optional().describe('Optional description rendered under the section header.'),
3315-
collapsible: z.boolean().default(false),
3316-
collapsed: z.boolean().default(false),
3315+
/**
3316+
* ## The collapse pair, declared once for both members
3317+
*
3318+
* Two independent booleans, both `.default(false)`, and the dependency
3319+
* between them is a RENDERER rule rather than anything parse does — so it
3320+
* has to be stated on the declaration or it reaches nobody. `collapsed`
3321+
* IMPLIES `collapsible`: a section that starts closed always carries the
3322+
* disclosure control that reopens it, `collapsed: true` needs no
3323+
* `collapsible` beside it, and it outranks an explicit `collapsible: false`
3324+
* (maintainer ruling 2026-09-18, letter A). Letter B — refusing the
3325+
* combination at the declaration — and letter C — a dev-only warning — were
3326+
* both REFUSED, so ⛔ neither this schema nor a lint rule may grow one:
3327+
* `collapsed: true` alone is a CORRECT spelling of "collapsed by default",
3328+
* which is why the ruling made the renderer widen instead.
3329+
*
3330+
* ⚠️ The pair is NOT normalized at parse, in either direction — measured
3331+
* against the built package: `{ collapsed: true }` parses to
3332+
* `{ collapsible: false, collapsed: true }`, verbatim, and
3333+
* `safeParseAsync` agrees. So a consumer reading the parsed `collapsible`
3334+
* is reading what the author typed, NOT whether a control renders; it
3335+
* applies the implication itself, from the declaration and never from live
3336+
* collapse state (deriving it from the latter deletes the control the
3337+
* moment the reader opens the section). That is the opposite of the
3338+
* `fieldGroups` pair on `ObjectSchema`, which a parse-time mapping folds
3339+
* onto the `collapse` enum — ⛔ do not carry a reading across.
3340+
*
3341+
* Only `true` is refused on a wizard step and beside `group`; `false` is
3342+
* accepted in both, because it declares exactly what those surfaces already
3343+
* deliver (see `trueOnlyDerivedKeys` below and the FormViewSchema wizard
3344+
* refinement).
3345+
*/
3346+
collapsible: z.boolean().default(false).describe(
3347+
'Whether the section renders a disclosure control, so a reader can close it and open it again. '
3348+
+ 'Default `false`: a section declaring neither collapse key is always open and shows no control. '
3349+
+ '⚠️ `collapsed: true` IMPLIES this key — an explicit `collapsible: false` beside it does NOT take '
3350+
+ 'the control away (ruled 2026-09-18). The renderer resolves that from the DECLARATION; parse never '
3351+
+ 'rewrites the pair, so a parsed section still reports the `false` that was authored. Only `true` is '
3352+
+ 'refused on a wizard step and beside `group`; `false` is accepted in both, because it declares '
3353+
+ 'exactly what those surfaces already deliver.',
3354+
),
3355+
collapsed: z.boolean().default(false).describe(
3356+
'Whether the section starts closed. Default `false`. ⚠️ `collapsed: true` IMPLIES `collapsible` and is '
3357+
+ 'sufficient ON ITS OWN — a section that starts closed always carries the disclosure control that '
3358+
+ 'reopens it, and it outranks an explicit `collapsible: false` (ruled 2026-09-18; refusing the '
3359+
+ 'combination at the declaration, and warning on it, were both rejected — nobody can depend on a '
3360+
+ 'section that cannot be opened). The implication is a renderer rule, never a parse-time rewrite: '
3361+
+ '`{ collapsed: true }` still parses to `collapsible: false, collapsed: true`, so the parsed '
3362+
+ '`collapsible` must never be read as "a control renders". Only `true` is refused on a wizard step (steps do not '
3363+
+ 'collapse) and beside `group`, whose field group declares the pair.',
3364+
),
33173365
/**
33183366
* Conditional-visibility predicate (CEL) — the whole section is shown only
33193367
* when TRUE (ADR-0089, canonical `*When` name). Same per-layer binding root as

0 commit comments

Comments
 (0)