diff --git a/.changeset/section-component-editability-boundary.md b/.changeset/section-component-editability-boundary.md new file mode 100644 index 0000000000..da993b78ae --- /dev/null +++ b/.changeset/section-component-editability-boundary.md @@ -0,0 +1,49 @@ +--- +"@objectstack/spec": patch +--- + +docs(spec): a form section / page component gates visibility only — say so, and tell `disabled` where it belongs (#7887) + +`FormSectionSchema` and `PageComponentSchema` declare no `disabled`, `readonly` +or `readonlyWhen` slot. Writing one has always been a loud parse error, but a +**bare** one: the message named the offending key and offered nothing, because +there is nothing on those shapes to point a rename at. An author — increasingly +an AI one — had no way to tell "this key is mis-spelled" from "this key belongs +somewhere else entirely". + +It is the second. Ruled a **boundary, not a gap** (maintainer, 2026-08-12): +sections and page components gate *visibility*; **editability lives on fields**. +Neither shape has read-only semantics of its own for anything to enforce, so a +slot here would be declared-but-unenforced from the day it landed — the ADR-0049 +class this repo is retiring elsewhere. + +So no key was added and no alias row was registered. What changed is the +sentence the rejection carries. `disabled`, `disabledWhen`, `readonly`, +`readOnly`, `readonlyWhen` and `editable` on either shape now answer with the +boundary and the destination: + +> Editability is a FIELD-level concern. This shape gates VISIBILITY only — a +> deliberate boundary, not a missing key (#7887): a section / page component has +> no read-only semantics of its own to enforce. Write `readonly: true` (or the +> conditional `readonlyWhen` predicate) on the form field(s) inside it instead; +> to hide the whole section or component, use `visibleWhen`. + +It points at **`readonlyWhen`** and never at `disabledWhen`, which exists on no +field surface: `field.zod.ts` renames `disabled` to `readonly` for exactly that +reason. + +**Acceptance is unchanged, in both directions.** Every metadata document that +parsed before parses identically, and every key rejected before is still +rejected — a guidance string is not an accepted key, and the pins assert both. +The package's public API surface does not move either: the new options table +lives in `shared/editability-boundary.ts`, which the barrel deliberately does not +re-export, alongside the `strictObject` machinery it belongs to. + +**The prescription is filed on those two shapes, not on the table they share.** +`VISIBILITY_STRICT_OPTIONS` has a third consumer, `FormFieldSchema`, which is +the one view/page shape that *does* answer `disabled` — through its own +`disabled → readonly` rename. A guidance set consumes a key before the rename +channel is ever consulted, so filing this family in the shared table would have +replaced the family's one correct pointer with a redirect away from it. Section +and component take a new `VISIBILITY_ONLY_STRICT_OPTIONS`; the field shape keeps +the bare options and its message is byte-for-byte what it was. diff --git a/packages/spec/src/shared/editability-boundary.test.ts b/packages/spec/src/shared/editability-boundary.test.ts new file mode 100644 index 0000000000..a2f8327fbd --- /dev/null +++ b/packages/spec/src/shared/editability-boundary.test.ts @@ -0,0 +1,256 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #7887 — the section / page-component **editability boundary**, asserted. + * + * The maintainer's ruling of 2026-08-12, operative sentence: *"`FormSectionSchema` + * / `PageComponentSchema` gate **visibility only**; editability lives on fields. + * No `disabled` / `readonly` / `disabledWhen` slot is added to those shapes, and + * no alias row is registered for them."* The deliverable is therefore text-face + * only: the accepted set does not move, the rejected set does not move, and the + * single thing that changes is the sentence an author reads when they write an + * editability key on a shape that has no editability semantics. + * + * ## What each section below would catch + * + * 1. **the prescription reaches a real author** — asserted on the actual + * `unrecognized_keys` message from `safeParse`, never on the options table. + * A row filed in a table nothing consults is exactly the dead-entry shape + * `alias-integrity.test.ts` exists for; reading the message back is the only + * assertion that cannot pass that way. + * 2. **it points at `readonlyWhen`, and never at `disabledWhen`** — `field.zod.ts` + * renames `disabled → readonly` and records that "a field has `readonlyWhen`, + * not `disabledWhen`" (#7832). A prescription naming `disabledWhen` would send + * the author to a key that exists on no field surface, which is worse than the + * bare rejection it replaced. + * 3. **the field surface is untouched** — the trap this card was tiered up for. + * `VISIBILITY_STRICT_OPTIONS` is shared with `FormFieldSchema`, which answers + * `disabled` through its OWN alias row. A guidanceSet match `continue`s past + * the rename channel, so filing this family in the shared table would have + * replaced the one correct pointer in the family with a redirect away from it. + * 4. **acceptance is byte-identical** — the lane's admission criterion. A + * guidance string must never become an accepted key. + * + * The options table itself lives in `editability-boundary.ts`, which the + * `shared/index.ts` barrel deliberately does not re-export — so the package's + * public API surface does not move either (`check:api-surface`), which is the + * same claim one level out. + */ + +import { describe, it, expect } from 'vitest'; + +import { FormFieldSchema, FormSectionSchema } from '../ui/view.zod'; +import { PageComponentSchema } from '../ui/page.zod'; +import { VISIBILITY_ONLY_STRICT_OPTIONS } from './editability-boundary'; +import { keySetMatches } from './suggestions.zod'; + +/** + * The `unrecognized_keys` message for `value`, or a loud failure. + * + * Same helper, same reasoning, as `visible-when-alias-guidance.test.ts`: the + * probe bodies are minimal and may also miss a required key, so a whole-error + * stringify could let an assertion pass on text from an unrelated issue. + */ +function unknownKeyMessage( + schema: { safeParse: (v: unknown) => { success: boolean; error?: unknown } }, + value: unknown, +): string { + const r = schema.safeParse(value); + expect(r.success, `expected REJECTION, got a successful parse of ${JSON.stringify(value)}`).toBe(false); + const issues = (r.error as { issues?: Array<{ code?: string; message?: string }> }).issues ?? []; + const hit = issues.find((i) => i.code === 'unrecognized_keys'); + expect(hit, `no \`unrecognized_keys\` issue in ${JSON.stringify(issues)}`).toBeDefined(); + return hit?.message ?? ''; +} + +/** Minimal bodies that reach each surface's unknown-key path. */ +const SECTION = { fields: [] } as const; +const COMPONENT = { type: 'text' } as const; +const FORM_FIELD = { field: 'probe' } as const; + +/** The two shapes the ruling names, and nothing else. */ +const VISIBILITY_ONLY: ReadonlyArray<[string, { safeParse: (v: unknown) => { success: boolean; error?: unknown } }, object]> = [ + ['FormSectionSchema', FormSectionSchema, SECTION], + ['PageComponentSchema', PageComponentSchema, COMPONENT], +]; + +/** The spellings the boundary set answers. */ +const EDITABILITY_KEYS = ['disabled', 'disabledWhen', 'readonly', 'readOnly', 'readonlyWhen', 'editable'] as const; + +// =========================================================================== +// 1. The guidance reaches an author — on the real parse error +// =========================================================================== +describe('#7887 — the boundary prescription an author actually sees', () => { + it.each(VISIBILITY_ONLY)('%s answers `disabled` with the boundary, not a bare refusal', (_n, schema, base) => { + const m = unknownKeyMessage(schema, { ...base, disabled: true }); + expect(m).toContain('Editability is a FIELD-level concern'); + expect(m).toContain('gates VISIBILITY only'); + // Rendered through the shared template's prescription channel — the bullet + // is what puts it directly after the key statement and before the history + // sentence (#5955's ordering, pinned for this family in `ui/view.test.ts`). + expect(m).toContain('\n • Editability is a FIELD-level concern'); + }); + + it.each(VISIBILITY_ONLY)('%s answers the whole editability family, not just `disabled`', (_n, schema, base) => { + for (const key of EDITABILITY_KEYS) { + expect( + unknownKeyMessage(schema, { ...base, [key]: 'x' }), + `\`${key}\` should reach the boundary prescription`, + ).toContain('Editability is a FIELD-level concern'); + } + }); + + it.each(VISIBILITY_ONLY)('%s emits the prescription ONCE for a body carrying several of them', (_n, schema, base) => { + // The property that makes this a SET rather than N exact entries: one + // paragraph per message, however many members were written. + const m = unknownKeyMessage(schema, { ...base, disabled: true, readonly: true, editable: false }); + expect(m.split('Editability is a FIELD-level concern')).toHaveLength(2); + // …and every offending key is still named. + for (const key of ['disabled', 'readonly', 'editable']) expect(m).toContain(`\`${key}\``); + }); + + it.each(VISIBILITY_ONLY)('%s still puts the history sentence last (the #5955 order survives the new set)', (_n, schema, base) => { + const m = unknownKeyMessage(schema, { ...base, disabled: true }); + const history = 'Before ADR-0089 D3a these were dropped silently'; + expect(m.indexOf('Editability is a FIELD-level concern')).toBeLessThan(m.indexOf(history)); + }); +}); + +// =========================================================================== +// 2. It names `readonlyWhen` — and must never name `disabledWhen` +// =========================================================================== +describe('#7887 — the prescription points at a key that exists', () => { + it.each(VISIBILITY_ONLY)('%s names the field-level `readonly` / `readonlyWhen` pair', (_n, schema, base) => { + const m = unknownKeyMessage(schema, { ...base, disabled: true }); + expect(m).toContain('`readonly: true`'); + expect(m).toContain('`readonlyWhen`'); + }); + + it.each(VISIBILITY_ONLY)('%s never names `disabledWhen` — no field surface declares it', (_n, schema, base) => { + // `field.zod.ts` renames `disabled → readonly` precisely because a field + // has `readonlyWhen`, not `disabledWhen` (#7832). Pointing at the latter + // would be a rejection that hands the author their next rejection. + const m = unknownKeyMessage(schema, { ...base, disabledWhen: 'record.locked' }); + expect(m).toContain('Editability is a FIELD-level concern'); + // The offending key is echoed back in the front matter, so the prohibition + // is on the PRESCRIPTION text, which is everything after the bullet. + const prescription = m.slice(m.indexOf('\n • ')); + expect(prescription).not.toContain('disabledWhen'); + }); + + it('the boundary also names the visibility escape hatch, and that key really is accepted', () => { + const m = unknownKeyMessage(FormSectionSchema, { ...SECTION, disabled: true }); + expect(m).toContain('`visibleWhen`'); + expect(FormSectionSchema.safeParse({ ...SECTION, visibleWhen: 'record.x' }).success).toBe(true); + expect(PageComponentSchema.safeParse({ ...COMPONENT, visibleWhen: 'record.x' }).success).toBe(true); + }); +}); + +// =========================================================================== +// 3. The field surface is UNCHANGED — the shared-table trap +// =========================================================================== +describe('#7887 — `FormFieldSchema` sees exactly what it saw before', () => { + it('`disabled` on a form field still renames onto `readonly`, with no boundary text', () => { + const m = unknownKeyMessage(FormFieldSchema, { ...FORM_FIELD, disabled: true }); + expect(m).toContain('Did you mean `disabled` → `readonly`?'); + // The regression this whole file exists to catch. Hoisting + // `EDITABILITY_BOUNDARY_KEYS` into `VISIBILITY_STRICT_OPTIONS` makes the set + // fire here, and a set match `continue`s past the rename channel — so the + // line above would vanish and this line would appear, redirecting a field + // author AWAY from the one surface where `readonly` is real. + expect(m).not.toContain('Editability is a FIELD-level concern'); + }); + + it('the boundary options are filed on the two visibility-only shapes, never the shared table', () => { + const names = (VISIBILITY_ONLY_STRICT_OPTIONS.guidanceSets ?? []).map((s) => s.name); + expect(names).toContain('EDITABILITY_BOUNDARY_KEYS'); + // The ADR-0089 set is still there and still first — the boundary set is an + // addition, not a replacement. + expect(names[0]).toBe('VISIBILITY_KEY_PATTERN'); + }); + + it('no editability key matches `VISIBILITY_KEY_PATTERN`, so set order is not load-bearing', () => { + // Both sets live on one table. If a future edit widened the visibility + // pattern to reach (say) `editable`, declaration order would silently start + // deciding which prescription an author reads — the tie-break + // `alias-integrity.test.ts` forbids any in-repo table from depending on. + const visibility = (VISIBILITY_ONLY_STRICT_OPTIONS.guidanceSets ?? []) + .find((s) => s.name === 'VISIBILITY_KEY_PATTERN'); + expect(visibility).toBeDefined(); + for (const key of EDITABILITY_KEYS) { + expect(keySetMatches(visibility!, key), `\`${key}\` is claimed by both sets`).toBe(false); + } + }); + + it('no alias row was registered for the boundary — the ruling forbids one', () => { + // "no alias row is registered for them — an alias would declare a key the + // runtime does not honour". An alias TARGET must be a key the shape accepts + // (`alias-integrity.test.ts`), and neither shape accepts any of these, so a + // row here would be a pointer into a second rejection. + for (const table of [VISIBILITY_ONLY_STRICT_OPTIONS]) { + for (const key of EDITABILITY_KEYS) { + expect(table.aliases?.[key], `\`${key}\` must not have an alias row`).toBeUndefined(); + } + } + }); +}); + +// =========================================================================== +// 4. Acceptance is byte-identical — a guidance string is not a key +// =========================================================================== +describe('#7887 — no acceptance change', () => { + it.each(VISIBILITY_ONLY)('%s still REJECTS every editability spelling', (_n, schema, base) => { + for (const key of EDITABILITY_KEYS) { + expect( + schema.safeParse({ ...base, [key]: true }).success, + `\`${key}\` must stay rejected — this card curates messages, it does not widen the shape`, + ).toBe(false); + } + }); + + it('a representative section that parsed before still parses, unchanged in output', () => { + const authored = { + name: 'billing', + label: 'Billing', + description: 'Invoicing details', + collapsible: true, + collapsed: false, + columns: 2, + visibleOn: 'record.type == "customer"', + fields: ['amount', { field: 'currency', readonly: true }], + }; + const r = FormSectionSchema.safeParse(authored); + expect(r.success).toBe(true); + // The ADR-0089 fold still runs, and the field-level `readonly` inside is + // still the accepted way to say what `disabled` on the section cannot. + // `ExpressionInputSchema` normalizes the authored string to a + // `{ dialect, source }` pair; the fold is about WHICH KEY carries it. + expect((r.data as { visibleWhen?: { source?: string } }).visibleWhen?.source) + .toBe('record.type == "customer"'); + expect((r.data as { visibleOn?: unknown }).visibleOn).toBeUndefined(); + }); + + it('a representative page component that parsed before still parses, unchanged in output', () => { + const authored = { + type: 'record:form', + id: 'main_form', + label: 'Details', + properties: { columns: 2 }, + className: 'p-4', + visibility: 'current_user.is_admin', + }; + const r = PageComponentSchema.safeParse(authored); + expect(r.success).toBe(true); + expect((r.data as { visibleWhen?: { source?: string } }).visibleWhen?.source) + .toBe('current_user.is_admin'); + expect((r.data as { visibility?: unknown }).visibility).toBeUndefined(); + }); + + it.each(VISIBILITY_ONLY)('%s keeps its bare message for a key in no family at all', (_n, schema, base) => { + // The new set must claim the editability family and nothing beyond it: an + // unrelated typo still gets the front matter plus history and no bullet. + const m = unknownKeyMessage(schema, { ...base, totallyUnrelatedKey: true }); + expect(m).toContain('`totallyUnrelatedKey`'); + expect(m).not.toContain(' • '); + }); +}); diff --git a/packages/spec/src/shared/editability-boundary.ts b/packages/spec/src/shared/editability-boundary.ts new file mode 100644 index 0000000000..3f7229aa39 --- /dev/null +++ b/packages/spec/src/shared/editability-boundary.ts @@ -0,0 +1,109 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * # The section / page-component **editability boundary** (#7887) + * + * Maintainer ruling, 2026-08-12: `FormSectionSchema` (`ui/view.zod.ts`) and + * `PageComponentSchema` (`ui/page.zod.ts`) gate **visibility only**; editability + * lives on fields. No `disabled` / `readonly` / `readonlyWhen` slot is added to + * either shape, and no alias row is registered for them — an alias names a key + * the shape must then accept, and one the runtime does not honour is the + * ADR-0049 declared-but-unenforced class this repo is retiring elsewhere. + * + * What the ruling *does* buy the author is this module: the rejection stops + * being bare and starts naming where the key belongs. + * + * ## Package-internal on purpose — this module is NOT in `shared/index.ts` + * + * It sits beside `strict-object.ts` and `alias-probe.ts` in the set of shared + * modules the barrel deliberately does not re-export. An unknown-key options + * table is machinery for declaring schemas in *this* package, and its own type + * (`StrictObjectOptions`) is not public either — a published const of an + * unpublishable type is an export no consumer can even annotate. + * + * It also keeps the #7887 claim exactly true: the card's whole deliverable is + * that nothing observable moves except the sentence an author reads, and the + * package's public API surface (`check:api-surface`) does not move at all. + */ + +import type { StrictObjectOptions } from './strict-object'; +import type { KeySetGuidance } from './suggestions.zod'; +import { VISIBILITY_STRICT_OPTIONS } from './visibility'; + +/** + * The editability vocabulary an author reaches for on a shape that gates + * **visibility only**. + * + * Every spelling here is rejected by `FormSectionSchema` and + * `PageComponentSchema` today and stays rejected: this set changes the MESSAGE, + * never the verdict. `readOnly` sits alongside `readonly` because set + * membership is matched case-sensitively (the rename channel is what folds + * case, and a set match `continue`s past it). + */ +const EDITABILITY_BOUNDARY_KEYS = [ + 'disabled', + 'disabledWhen', + 'readonly', + 'readOnly', + 'readonlyWhen', + 'editable', +] as const; + +/** + * The ruling rendered as the thing an author actually reads: **boundary, not + * gap.** + * + * Deliberately points at **`readonlyWhen`** and not at `disabledWhen`: + * `field.zod.ts` renames `disabled → readonly` and records in its own comment + * that "a field has `readonlyWhen`, not `disabledWhen`" (#7832). Naming + * `disabledWhen` here would send an author to a key that exists on no field + * surface at all — a rejection that hands them their next rejection. + */ +const EDITABILITY_BOUNDARY_GUIDANCE: KeySetGuidance = { + name: 'EDITABILITY_BOUNDARY_KEYS', + keys: EDITABILITY_BOUNDARY_KEYS, + prescription: + 'Editability is a FIELD-level concern. This shape gates VISIBILITY only — a ' + + 'deliberate boundary, not a missing key (#7887): a section / page component has ' + + 'no read-only semantics of its own to enforce. Write `readonly: true` (or the ' + + 'conditional `readonlyWhen` predicate) on the form field(s) inside it instead; to ' + + 'hide the whole section or component, use `visibleWhen`.', +}; + +/** + * {@link VISIBILITY_STRICT_OPTIONS} for the two shapes that gate visibility and + * **nothing else** — `FormSectionSchema` and `PageComponentSchema`. + * + * ## Why the boundary prescription is filed HERE and not in the shared table + * + * `VISIBILITY_STRICT_OPTIONS` has **three** consumers, and the third — + * `FormFieldSchema` — is the one view/page shape that *does* answer `disabled`, + * through its own `aliases: { disabled: 'readonly' }` row (`view.zod.ts`, whose + * comment at that site rejects shared-table filing for exactly this reason). + * Adding `EDITABILITY_BOUNDARY_KEYS` to the shared options would land it on that + * table too, and the consequences are not cosmetic: + * + * - `strictUnknownKeyError` consults exact `guidance` → `guidanceSets` → + * `aliases`, and a set match `continue`s past the rename. The field author who + * writes `disabled` would stop seeing *"Did you mean `disabled` → `readonly`?"* + * and start being told editability is somewhere else — on the one surface + * where it is right there. + * - `alias-integrity.test.ts` would go **red**, not quietly wrong, in two + * places: its #7889 check fails any alias row a guidanceSet on the same table + * already consumes, and its #6619 check fails a set member the shape + * *declares* — which `readonly` is, on `FormFieldSchema`. + * + * So the two visibility-only shapes take these options and `FormFieldSchema` + * keeps the bare ones. The prescription text is written once, here. + */ +export const VISIBILITY_ONLY_STRICT_OPTIONS: StrictObjectOptions = { + ...VISIBILITY_STRICT_OPTIONS, + guidanceSets: [ + // Declaration order decides among sets. Nothing in + // `EDITABILITY_BOUNDARY_KEYS` matches `VISIBILITY_KEY_PATTERN` + // (`/vis|conceal|hidden|show.?when/i`), so the order is not load-bearing — + // pinned in `editability-boundary.test.ts` so it cannot quietly become so. + ...(VISIBILITY_STRICT_OPTIONS.guidanceSets ?? []), + EDITABILITY_BOUNDARY_GUIDANCE, + ], +}; diff --git a/packages/spec/src/shared/visible-when-alias-guidance.test.ts b/packages/spec/src/shared/visible-when-alias-guidance.test.ts index 1b71027cfd..6bd493e224 100644 --- a/packages/spec/src/shared/visible-when-alias-guidance.test.ts +++ b/packages/spec/src/shared/visible-when-alias-guidance.test.ts @@ -174,10 +174,17 @@ describe('#7832 — the deliberate gaps (an alias here would name a key the shap ['SelectOptionSchema', SelectOptionSchema, OPTION], ['FormSectionSchema', FormSectionSchema, SECTION], ['PageComponentSchema', PageComponentSchema, COMPONENT], - ])('%s declares no `disabledWhen` / `disabled` / `readonly`, so `disabled` stays uncurated', (_n, schema, base) => { - // Rejected — loudly, with the surface named — just without a pointer, - // because there is nothing on this shape to point at. If any of these ever + ])('%s declares no `disabledWhen` / `disabled` / `readonly`, so no alias row is filed', (_n, schema, base) => { + // Rejected — loudly, with the surface named — and with no RENAME, because + // there is nothing on this shape to point a rename at. If any of these ever // gains a disabled-ish key, this assertion fails and the row becomes owed. + // + // #7887 ruled the absence a BOUNDARY rather than a gap and gave the two + // view/page shapes a prescription saying so (`EDITABILITY_BOUNDARY_KEYS`, + // pinned in `editability-boundary.test.ts`). That is the guidance channel, + // not the alias channel: no key was added, no alias row was registered, and + // every assertion below is unchanged and green by construction. + // `SelectOptionSchema` was out of that ruling's scope and stays bare. for (const target of ['disabledWhen', 'disabled', 'readonly']) { expect( schema.safeParse({ ...base, [target]: 'x' }).success, diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 95ca7186cd..d280c255f5 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -3,7 +3,8 @@ import { z } from 'zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { ExpressionInputSchema } from '../shared/expression.zod'; -import { normalizeVisibleWhen, VISIBILITY_STRICT_OPTIONS } from '../shared/visibility'; +import { normalizeVisibleWhen } from '../shared/visibility'; +import { VISIBILITY_ONLY_STRICT_OPTIONS } from '../shared/editability-boundary'; import { SortItemSchema } from '../shared/enums.zod'; import { FilterConditionSchema } from '../data/filter.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; @@ -100,8 +101,26 @@ export const ElementDataSourceSchema = lazySchema(() => strictObject({ * now under `alias-integrity.test.ts` and with an edit-distance rename for the * page-component keys the hand-written map had no channel for (`classNam` → * `className`). + * + * ## A component gates VISIBILITY, not editability (#7887 — boundary, not gap) + * + * There is no `disabled`, `readonly` or `readonlyWhen` on a page component, and + * that is a **deliberate boundary** ruled on 2026-08-12, not a slot nobody got + * round to adding: **editability lives on fields.** A component decides whether + * it renders at all (`visibleWhen`); whether an input inside it can be edited is + * the field's own `readonly` / `readonlyWhen`, enforced by the field renderer + * that owns the input. Nothing in the platform reads a component-level read-only + * flag, so declaring one would ship the ADR-0049 declared-but-unenforced shape + * this repo is retiring elsewhere. + * + * Writing one anyway stays a loud parse error — unchanged — and since #7887 that + * error carries `EDITABILITY_BOUNDARY_KEYS`' prescription naming the field-level + * keys, so the author is redirected instead of merely refused. A widget with its + * own enabled/disabled notion expresses it inside {@link + * PageComponentSchema.properties}, which is that widget's own contract + * (`component.zod.ts`) and not this shape's. */ -export const PageComponentSchema = lazySchema(() => strictObject(VISIBILITY_STRICT_OPTIONS, { +export const PageComponentSchema = lazySchema(() => strictObject(VISIBILITY_ONLY_STRICT_OPTIONS, { /** Definition */ type: z.union([ PageComponentType, diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 8c2a67d5ce..1bc31cdfe8 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -7,6 +7,7 @@ import { strictObject, strictObjectError } from '../shared/strict-object'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { ExpressionInputSchema } from '../shared/expression.zod'; import { normalizeVisibleWhen, VISIBILITY_STRICT_OPTIONS } from '../shared/visibility'; +import { VISIBILITY_ONLY_STRICT_OPTIONS } from '../shared/editability-boundary'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { ChartTypeSchema } from './chart.zod'; import { SharingConfigSchema } from './sharing.zod'; @@ -1772,6 +1773,16 @@ const FormFieldBaseSchema = lazySchema(() => { // match `VISIBILITY_KEY_PATTERN` and are already answered by the shared // ADR-0089 prescription, which consumes the key before the rename channel // is consulted, so an alias for either would be dead on arrival. + // + // #7887 filed the OTHER half of that split from the same reasoning and in + // the same direction: the two sibling shapes now carry an editability + // BOUNDARY prescription (`shared/editability-boundary.ts`), and it is + // filed on those two rather than shared, because a `disabled`-matching + // guidanceSet on THIS table would consume the key before the rename below + // ever runs — killing the one pointer that is correct here, and turning + // `alias-integrity.test.ts`'s #7889 reachability check red. The two + // decisions are one rule read from both ends: the shared table may only + // carry what is true of all three surfaces. aliases: { disabled: 'readonly' }, }, shape), }); @@ -1847,8 +1858,26 @@ export const FormFieldSchema: z.ZodType = lazySchema( * set-keyed form and the conversion became the same one call every other closed * shape in this package makes. The `.transform()` that folds * `visibleOn` → `visibleWhen` is unchanged and still runs after the parse. - */ -export const FormSectionSchema = lazySchema(() => strictObject(VISIBILITY_STRICT_OPTIONS, { + * + * ## A section gates VISIBILITY, not editability (#7887 — boundary, not gap) + * + * There is no `disabled`, `readonly` or `readonlyWhen` on a section, and that is + * a **deliberate boundary** ruled on 2026-08-12, not a slot nobody got round to + * adding: **editability lives on fields.** A section decides whether its fields + * are *shown* (`visibleWhen`, plus `collapsible` / `collapsed` for how); whether + * a shown field can be *edited* is the field's own `readonly` / + * {@link FormFieldSchema.readonlyWhen}, enforced by the field renderer that owns + * the input. Nothing in the platform reads a section-level read-only flag, so + * declaring one would ship the ADR-0049 declared-but-unenforced shape this repo + * is retiring elsewhere. + * + * Writing one anyway stays a loud parse error — unchanged — and since #7887 that + * error carries `EDITABILITY_BOUNDARY_KEYS`' prescription naming the field-level + * keys, so the author is redirected instead of merely refused. To make a whole + * section non-editable, mark its fields `readonly`; to make it conditionally + * non-editable, give each field a `readonlyWhen` predicate. + */ +export const FormSectionSchema = lazySchema(() => strictObject(VISIBILITY_ONLY_STRICT_OPTIONS, { /** * Stable identifier for translation lookup. snake_case convention. * When provided, translation bundles can target this section's `label`