From edbe4603fd626c11eac2266b992473111775623b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 19:45:31 +0000 Subject: [PATCH 1/2] docs(spec): rule the section/component editability boundary and say it in the rejection (#7887) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FormSectionSchema` / `PageComponentSchema` gate visibility only; editability lives on fields (maintainer ruling, 2026-08-12). No slot and no alias row is added — the rejection now carries a guidance string naming the field-level `readonly` / `readonlyWhen` pair instead of refusing bare. Filed as `VISIBILITY_ONLY_STRICT_OPTIONS` on those two shapes rather than in the shared `VISIBILITY_STRICT_OPTIONS`: the third consumer, `FormFieldSchema`, answers `disabled` through its own rename row, and a guidanceSet consumes a key before the rename channel is ever reached. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016YBUGvukaeVu9DjKdsHJa9 --- .../section-component-editability-boundary.md | 46 ++++ packages/spec/export-origins/shared.json | 1 + .../src/shared/editability-boundary.test.ts | 251 ++++++++++++++++++ packages/spec/src/shared/visibility.ts | 85 ++++++ .../visible-when-alias-guidance.test.ts | 13 +- packages/spec/src/ui/page.zod.ts | 22 +- packages/spec/src/ui/view.zod.ts | 38 ++- 7 files changed, 448 insertions(+), 8 deletions(-) create mode 100644 .changeset/section-component-editability-boundary.md create mode 100644 packages/spec/src/shared/editability-boundary.test.ts diff --git a/.changeset/section-component-editability-boundary.md b/.changeset/section-component-editability-boundary.md new file mode 100644 index 0000000000..17c39e7615 --- /dev/null +++ b/.changeset/section-component-editability-boundary.md @@ -0,0 +1,46 @@ +--- +"@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 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/export-origins/shared.json b/packages/spec/export-origins/shared.json index 93a59de0a9..e87fd99d67 100644 --- a/packages/spec/export-origins/shared.json +++ b/packages/spec/export-origins/shared.json @@ -91,6 +91,7 @@ "TemplateExpressionInput": "src/shared/expression.zod.ts#TemplateExpressionInput (type)", "TemplateExpressionInputSchema": "src/shared/expression.zod.ts#TemplateExpressionInputSchema (const)", "VISIBILITY_ALIAS_KEYS": "src/shared/visibility.ts#VISIBILITY_ALIAS_KEYS (const)", + "VISIBILITY_ONLY_STRICT_OPTIONS": "src/shared/visibility.ts#VISIBILITY_ONLY_STRICT_OPTIONS (const)", "VISIBILITY_STRICT_OPTIONS": "src/shared/visibility.ts#VISIBILITY_STRICT_OPTIONS (const)", "ViewName": "src/shared/branded-types.zod.ts#ViewName (type)", "ViewNameParsed": "src/shared/branded-types.zod.ts#ViewNameParsed (type)", 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..4cbebdbe51 --- /dev/null +++ b/packages/spec/src/shared/editability-boundary.test.ts @@ -0,0 +1,251 @@ +// 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. + */ + +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 './visibility'; +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/visibility.ts b/packages/spec/src/shared/visibility.ts index 17648e698e..0fa0233e52 100644 --- a/packages/spec/src/shared/visibility.ts +++ b/packages/spec/src/shared/visibility.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { StrictObjectOptions } from './strict-object'; +import type { KeySetGuidance } from './suggestions.zod'; /** * # Conditional-visibility predicate normalization (ADR-0089) @@ -138,3 +139,87 @@ export const VISIBILITY_STRICT_OPTIONS: StrictObjectOptions = { }, ], }; + +/** + * The editability vocabulary an author reaches for on a shape that gates + * **visibility only** (#7887). + * + * 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 maintainer's 2026-08-12 ruling on #7887, rendered as the thing an author + * actually reads: **boundary, not gap.** + * + * A form section and a page component gate *visibility*; **editability lives on + * fields**. No `disabled` / `readonly` / `readonlyWhen` slot is added to either + * shape, and no alias row is registered for them either — 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. What the author + * gets instead is a prescription telling them where the key *does* belong. + * + * 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. + */ +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` (`ui/view.zod.ts`) and + * `PageComponentSchema` (`ui/page.zod.ts`). + * + * ## 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`, the + * 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: its #7889 + * check fails any alias row a guidanceSet on the same table already consumes. + * + * So the two visibility-only shapes take these options and `FormFieldSchema` + * keeps the bare ones. The prescription text itself 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..bcb81739e2 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -3,7 +3,7 @@ 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, VISIBILITY_ONLY_STRICT_OPTIONS } from '../shared/visibility'; import { SortItemSchema } from '../shared/enums.zod'; import { FilterConditionSchema } from '../data/filter.zod'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; @@ -100,8 +100,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..95f1e8c438 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -6,7 +6,11 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; 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 { + normalizeVisibleWhen, + VISIBILITY_ONLY_STRICT_OPTIONS, + VISIBILITY_STRICT_OPTIONS, +} from '../shared/visibility'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { ChartTypeSchema } from './chart.zod'; import { SharingConfigSchema } from './sharing.zod'; @@ -1772,6 +1776,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 (`VISIBILITY_ONLY_STRICT_OPTIONS`), 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 +1861,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` From 82443539c25516ee69d2b83344b29c51306b3103 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 20:35:11 +0000 Subject: [PATCH 2/2] refactor(spec): keep the editability-boundary options package-internal (#7887) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:api-surface` (repo-wide TypeScript Type Check job) went red: exporting `VISIBILITY_ONLY_STRICT_OPTIONS` from `shared/visibility.ts` put it in the public barrel and moved the package's API surface. Moved to a new `shared/editability-boundary.ts`, which the barrel deliberately does not re-export — the same posture as `strict-object.ts` and `alias-probe.ts`. `StrictObjectOptions`, the const's own type, is not public either, so a published value of that type is one no consumer could annotate. The public API surface and `export-origins` now both read unchanged, which is also the card's own claim one level out. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016YBUGvukaeVu9DjKdsHJa9 --- .../section-component-editability-boundary.md | 3 + packages/spec/export-origins/shared.json | 1 - .../src/shared/editability-boundary.test.ts | 7 +- .../spec/src/shared/editability-boundary.ts | 109 ++++++++++++++++++ packages/spec/src/shared/visibility.ts | 85 -------------- packages/spec/src/ui/page.zod.ts | 3 +- packages/spec/src/ui/view.zod.ts | 9 +- 7 files changed, 123 insertions(+), 94 deletions(-) create mode 100644 packages/spec/src/shared/editability-boundary.ts diff --git a/.changeset/section-component-editability-boundary.md b/.changeset/section-component-editability-boundary.md index 17c39e7615..da993b78ae 100644 --- a/.changeset/section-component-editability-boundary.md +++ b/.changeset/section-component-editability-boundary.md @@ -35,6 +35,9 @@ 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 diff --git a/packages/spec/export-origins/shared.json b/packages/spec/export-origins/shared.json index e87fd99d67..93a59de0a9 100644 --- a/packages/spec/export-origins/shared.json +++ b/packages/spec/export-origins/shared.json @@ -91,7 +91,6 @@ "TemplateExpressionInput": "src/shared/expression.zod.ts#TemplateExpressionInput (type)", "TemplateExpressionInputSchema": "src/shared/expression.zod.ts#TemplateExpressionInputSchema (const)", "VISIBILITY_ALIAS_KEYS": "src/shared/visibility.ts#VISIBILITY_ALIAS_KEYS (const)", - "VISIBILITY_ONLY_STRICT_OPTIONS": "src/shared/visibility.ts#VISIBILITY_ONLY_STRICT_OPTIONS (const)", "VISIBILITY_STRICT_OPTIONS": "src/shared/visibility.ts#VISIBILITY_STRICT_OPTIONS (const)", "ViewName": "src/shared/branded-types.zod.ts#ViewName (type)", "ViewNameParsed": "src/shared/branded-types.zod.ts#ViewNameParsed (type)", diff --git a/packages/spec/src/shared/editability-boundary.test.ts b/packages/spec/src/shared/editability-boundary.test.ts index 4cbebdbe51..a2f8327fbd 100644 --- a/packages/spec/src/shared/editability-boundary.test.ts +++ b/packages/spec/src/shared/editability-boundary.test.ts @@ -30,13 +30,18 @@ * 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 './visibility'; +import { VISIBILITY_ONLY_STRICT_OPTIONS } from './editability-boundary'; import { keySetMatches } from './suggestions.zod'; /** 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/visibility.ts b/packages/spec/src/shared/visibility.ts index 0fa0233e52..17648e698e 100644 --- a/packages/spec/src/shared/visibility.ts +++ b/packages/spec/src/shared/visibility.ts @@ -1,7 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { StrictObjectOptions } from './strict-object'; -import type { KeySetGuidance } from './suggestions.zod'; /** * # Conditional-visibility predicate normalization (ADR-0089) @@ -139,87 +138,3 @@ export const VISIBILITY_STRICT_OPTIONS: StrictObjectOptions = { }, ], }; - -/** - * The editability vocabulary an author reaches for on a shape that gates - * **visibility only** (#7887). - * - * 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 maintainer's 2026-08-12 ruling on #7887, rendered as the thing an author - * actually reads: **boundary, not gap.** - * - * A form section and a page component gate *visibility*; **editability lives on - * fields**. No `disabled` / `readonly` / `readonlyWhen` slot is added to either - * shape, and no alias row is registered for them either — 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. What the author - * gets instead is a prescription telling them where the key *does* belong. - * - * 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. - */ -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` (`ui/view.zod.ts`) and - * `PageComponentSchema` (`ui/page.zod.ts`). - * - * ## 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`, the - * 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: its #7889 - * check fails any alias row a guidanceSet on the same table already consumes. - * - * So the two visibility-only shapes take these options and `FormFieldSchema` - * keeps the bare ones. The prescription text itself 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/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index bcb81739e2..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_ONLY_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'; diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 95f1e8c438..1bc31cdfe8 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -6,11 +6,8 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { strictObject, strictObjectError } from '../shared/strict-object'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { ExpressionInputSchema } from '../shared/expression.zod'; -import { - normalizeVisibleWhen, - VISIBILITY_ONLY_STRICT_OPTIONS, - VISIBILITY_STRICT_OPTIONS, -} from '../shared/visibility'; +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'; @@ -1779,7 +1776,7 @@ const FormFieldBaseSchema = lazySchema(() => { // // #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 (`VISIBILITY_ONLY_STRICT_OPTIONS`), and it is + // 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