From 05b7333440653ad8dfcbf75eda31da6b228e0955 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 13 Aug 2026 05:59:38 +0000 Subject: [PATCH 1/2] docs(spec): per-shape surface strings, and the editability boundary on a select option (#8202, #8201) Each of the three view/page shapes now names itself in its unknown-key rejection, so the two deliberately contradictory answers to `disabled` (rename on a field, boundary on a section / page component) can be told apart. `SelectOptionSchema` inherits the #7887 ruling with its own prescription: withdraw the option with per-option `visibleWhen`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016YBUGvukaeVu9DjKdsHJa9 --- packages/spec/src/data/field.zod.ts | 36 +++ .../spec/src/shared/alias-integrity.test.ts | 34 ++- .../src/shared/editability-boundary.test.ts | 222 +++++++++++++++++- .../spec/src/shared/editability-boundary.ts | 94 +++++++- packages/spec/src/shared/visibility.ts | 21 ++ .../visible-when-alias-guidance.test.ts | 8 +- packages/spec/src/ui/page.zod.ts | 12 +- packages/spec/src/ui/view.test.ts | 17 +- packages/spec/src/ui/view.zod.ts | 20 +- 9 files changed, 447 insertions(+), 17 deletions(-) diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 4674e941cf..d19707c7eb 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -3,6 +3,11 @@ import { z } from 'zod'; import { retiredKey } from '../shared/retired-key'; import { strictObject } from '../shared/strict-object'; +// Package-internal, like `strict-object` itself — the `shared/index.ts` barrel +// deliberately does not re-export it, so nothing about the public API surface +// moves. No cycle back into this file: that module's only runtime import is +// `shared/visibility.ts`, which imports nothing at runtime. +import { SELECT_OPTION_EDITABILITY_GUIDANCE } from '../shared/editability-boundary'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { SystemIdentifierSchema } from '../shared/identifiers.zod'; import { ExpressionInputSchema } from '../shared/expression.zod'; @@ -147,10 +152,41 @@ const FIELD_HISTORY = 'Until #4001 closed this shape these were dropped silently — the field was still created, ' + 'minus whatever the key was meant to constrain, protect or compute.'; +/** + * ## An option is offered or withheld — it is never "shown but unselectable" + * (#8201 — boundary, not gap) + * + * There is no `disabled`, `readonly` or `readonlyWhen` on a select option, and + * that is a **deliberate boundary** rather than a slot nobody added. It is the + * 2026-08-12 #7887 ruling reaching its third shape, on that ruling's own + * premise re-measured for this one: nothing in the object-field pipeline these + * options feed reads a per-option enabled/disabled flag — objectui's select and + * radio widgets treat the FIELD-level state as the single authority — so + * declaring one here would ship the ADR-0049 declared-but-unenforced shape. + * (A shown-but-unselectable option does exist in objectui's SDUI component + * family, but on that package's own option vocabulary, not this shape.) + * + * Writing one anyway stays a loud parse error — unchanged — and since #8201 + * that error carries {@link SELECT_OPTION_EDITABILITY_GUIDANCE}, which points + * at the two things that are real: {@link SelectOptionSchema.visibleWhen} to + * withdraw THIS option (per record or, uniquely on this surface, per + * `current_user` — ADR-0068), and `readonly` / `readonlyWhen` on the FIELD to + * freeze the whole picker. + * + * If a non-selectable field option ever earns a real reader, that is a spec + * decision that widens the accepted set — this boundary records what the + * platform honours today, not a claim that the answer can never change. + */ export const SelectOptionSchema = lazySchema(() => strictObject({ surface: 'this select option', history: FIELD_HISTORY, aliases: { text: 'label', name: 'label', title: 'label', key: 'value', id: 'value', isDefault: 'default', selected: 'default', colour: 'color', visible: 'visibleWhen', showWhen: 'visibleWhen' }, + // #8201. No alias row for the editability family, per the same red line the + // mother ruling drew: an alias names a key the shape must then accept, and + // this shape accepts none of them. The set consumes those spellings before + // the rename channel runs, and none of the alias keys above is a member, so + // no existing pointer is shadowed (`alias-integrity.test.ts`, #7889). + guidanceSets: [SELECT_OPTION_EDITABILITY_GUIDANCE], }, { label: z.string().describe('Display label (human-readable, any case allowed)'), value: SystemIdentifierSchema.describe('Stored value (lowercase machine identifier)'), diff --git a/packages/spec/src/shared/alias-integrity.test.ts b/packages/spec/src/shared/alias-integrity.test.ts index f90b8c47b5..c99df6091e 100644 --- a/packages/spec/src/shared/alias-integrity.test.ts +++ b/packages/spec/src/shared/alias-integrity.test.ts @@ -971,10 +971,18 @@ describe('alias integrity — every table is a true claim about its schema', () // re-opening the blind spot. const bySurface = new Map(SURFACES.map((s) => [s.options.surface, s])); - const visibility = [...SURFACES].filter((s) => s.options.surface === 'this view/page schema'); - // Three call sites share the options: FormFieldBase (via strictObjectError), - // FormSection, PageComponent — distinct shapes, so distinct declarations. + // Three call sites share the OPTIONS; since #8202 they no longer share the + // `surface` string, so the selector names the three shapes instead of the + // one string they used to report. Same claim as before — those three + // declarations exist and each carries the ADR-0089 set — and a strictly + // stronger selector: it fails if a shape stops declaring through the + // folded table AND if one of them silently loses its own name. + const VISIBILITY_FAMILY = ['this form field', 'this form section', 'this page component']; + const visibility = [...SURFACES].filter((s) => VISIBILITY_FAMILY.includes(s.options.surface)); + // FormFieldBase (via strictObjectError), FormSection, PageComponent — + // distinct shapes, so distinct declarations. expect(visibility.length).toBeGreaterThanOrEqual(3); + expect(new Set(visibility.map((s) => s.options.surface)).size).toBe(3); for (const v of visibility) { expect(v.options.guidanceSets?.map((g) => g.name)).toContain('VISIBILITY_KEY_PATTERN'); } @@ -992,6 +1000,26 @@ describe('alias integrity — every table is a true claim about its schema', () expect(Object.keys(tenancy!.options.guidance ?? {}).sort()).toEqual(['crossTenantAccess', 'strategy']); }); + it('no live surface still reports the shared view/page FAMILY name (#8202)', () => { + // `VISIBILITY_STRICT_OPTIONS.surface` is the family's name, and every + // consumer overrides it with its own shape's (`'this form field'` / + // `'this form section'` / `'this page component'`) — because since #7887 + // the shapes answer the same key in two contradictory ways, and the answer + // is only readable if the message says which shape it came from. + // + // A fourth consumer that spreads the shared options and forgets the + // override inherits the family string silently: the message goes vague + // rather than wrong, so nothing else fails. This walk sees every table the + // package constructs, which makes it the one place that can notice. + const inherited = SURFACES + .filter((s) => s.options.surface === 'this view/page schema') + .map((s) => `${s.options.surface} (keys: ${Object.keys(s.shape).slice(0, 4).join(', ')}…)`); + expect( + inherited, + 'these declarations spread the shared visibility options without naming their own shape', + ).toEqual([]); + }); + it('the two maps #6619 MISSED are folded and judged here too (#6805)', () => { // #6619's inventory was two short, and both survivors were the same shape // as the three above — `unrecognized_keys` prescription tables attached to diff --git a/packages/spec/src/shared/editability-boundary.test.ts b/packages/spec/src/shared/editability-boundary.test.ts index a2f8327fbd..a0e1d51506 100644 --- a/packages/spec/src/shared/editability-boundary.test.ts +++ b/packages/spec/src/shared/editability-boundary.test.ts @@ -1,7 +1,22 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * #7887 — the section / page-component **editability boundary**, asserted. + * #7887 / #8202 / #8201 — the **editability boundary** and the surface strings + * that make it readable, asserted. + * + * Sections 1-4 are #7887's, unchanged in intent. Sections 5 and 6 finish the + * story #7887 started: + * + * - **§5 (#8202)** — each shape names ITSELF in its rejection. #7887 is what + * made this load-bearing rather than cosmetic: the same key now gets two + * deliberately contradictory answers depending on the shape (rename on a + * field, boundary on a section / component), and a reader holding the wrong + * one is told to move a key that is already in the right place. + * - **§6 (#8201)** — `SelectOptionSchema`, the third shape §3 of + * `visible-when-alias-guidance.test.ts` pins as declaring no disabled-ish + * key, gets the boundary too, with the destination that is true for an option + * (withdraw it with `visibleWhen`) rather than the containers' (put the key + * on the fields inside — an option has no inside). * * The maintainer's ruling of 2026-08-12, operative sentence: *"`FormSectionSchema` * / `PageComponentSchema` gate **visibility only**; editability lives on fields. @@ -39,9 +54,11 @@ import { describe, it, expect } from 'vitest'; +import { FieldSchema, SelectOptionSchema } from '../data/field.zod'; import { FormFieldSchema, FormSectionSchema } from '../ui/view.zod'; import { PageComponentSchema } from '../ui/page.zod'; -import { VISIBILITY_ONLY_STRICT_OPTIONS } from './editability-boundary'; +import { SELECT_OPTION_EDITABILITY_GUIDANCE, VISIBILITY_ONLY_STRICT_OPTIONS } from './editability-boundary'; +import { strictObjectDeclarations } from './strict-object'; import { keySetMatches } from './suggestions.zod'; /** @@ -67,6 +84,8 @@ function unknownKeyMessage( const SECTION = { fields: [] } as const; const COMPONENT = { type: 'text' } as const; const FORM_FIELD = { field: 'probe' } as const; +const OPTION = { label: 'A', value: 'aa' } as const; +const FIELD = { name: 'probe', type: 'text' } as const; /** The two shapes the ruling names, and nothing else. */ const VISIBILITY_ONLY: ReadonlyArray<[string, { safeParse: (v: unknown) => { success: boolean; error?: unknown } }, object]> = [ @@ -254,3 +273,202 @@ describe('#7887 — no acceptance change', () => { expect(m).not.toContain(' • '); }); }); + +// =========================================================================== +// 5. #8202 — every shape names ITSELF, so the two answers cannot be swapped +// =========================================================================== +describe('#8202 — the rejection says WHICH shape refused the key', () => { + /** Each shape, a probe body, and the name its rejection must give itself. */ + const NAMED: ReadonlyArray<[string, { safeParse: (v: unknown) => { success: boolean; error?: unknown } }, object, string]> = [ + ['FormFieldSchema', FormFieldSchema, FORM_FIELD, 'this form field'], + ['FormSectionSchema', FormSectionSchema, SECTION, 'this form section'], + ['PageComponentSchema', PageComponentSchema, COMPONENT, 'this page component'], + // Already named itself before this card; included because since #8201 its + // message carries a THIRD answer to `disabled`, so it joins the set of + // messages that must not be mistaken for one another. + ['SelectOptionSchema', SelectOptionSchema, OPTION, 'this select option'], + ]; + + it.each(NAMED)('%s opens with its own name, and names no sibling shape', (_n, schema, base, surface) => { + const m = unknownKeyMessage(schema, { ...base, disabled: true }); + expect(m.startsWith(`Unrecognized key(s) on ${surface}: \`disabled\`.`)).toBe(true); + for (const other of NAMED.map((row) => row[3])) { + if (other === surface) continue; + expect(m, `${surface}'s message also names ${other}`).not.toContain(other); + } + }); + + it('the two contradictory answers to `disabled` are each stamped with their shape', () => { + // The defect #8202 records, asserted from both ends at once: these two + // messages tell the author OPPOSITE things about the same key, on purpose. + // Before this card both opened `Unrecognized key(s) on this view/page + // schema`, so an author holding the section answer while looking at a field + // was being told to move a key that was already in the right place. + const field = unknownKeyMessage(FormFieldSchema, { ...FORM_FIELD, disabled: true }); + const section = unknownKeyMessage(FormSectionSchema, { ...SECTION, disabled: true }); + + expect(field).toContain('this form field'); + expect(field).toContain('Did you mean `disabled` → `readonly`?'); + expect(field).not.toContain('Editability is a FIELD-level concern'); + + expect(section).toContain('this form section'); + expect(section).toContain('Editability is a FIELD-level concern'); + expect(section).not.toContain('Did you mean'); + }); + + it('no shape reports the shared family string any more', () => { + // The family name is what the three shapes shared until #8202. A consumer + // that forgets to override inherits it silently, which is a message going + // vague rather than a test going red — so it is asserted here, and + // repo-wide over every live declaration in `alias-integrity.test.ts`. + for (const [name, schema, base] of NAMED) { + expect( + unknownKeyMessage(schema, { ...base, zzNotAKey: true }), + `${name} still inherits the shared surface string`, + ).not.toContain('this view/page schema'); + } + }); + + it('the shared table was not edited in place — it still carries the FAMILY name', () => { + // #8199's placement rule, read from the `surface` end: a table shared by + // several shapes may carry only what is true of all of them. Renaming the + // shared string to one shape's name would make it true of one consumer and + // wrong for the rest, which is the edit this assertion refuses. + expect(VISIBILITY_ONLY_STRICT_OPTIONS.surface).toBe('this view/page schema'); + }); +}); + +// =========================================================================== +// 6. #8201 — `SelectOptionSchema` inherits the ruling, with its OWN answer +// =========================================================================== +describe('#8201 — an option is offered or withheld, never shown-but-unselectable', () => { + const OPTION_TEXT = 'Editability is not a per-OPTION concern'; + + it('`disabled` on an option reaches the boundary, rendered as the template bullet', () => { + const m = unknownKeyMessage(SelectOptionSchema, { ...OPTION, disabled: true }); + expect(m).toContain(OPTION_TEXT); + expect(m).toContain(`\n • ${OPTION_TEXT}`); + }); + + it('the whole editability family reaches it, once per message', () => { + for (const key of EDITABILITY_KEYS) { + expect( + unknownKeyMessage(SelectOptionSchema, { ...OPTION, [key]: 'x' }), + `\`${key}\` should reach the option boundary prescription`, + ).toContain(OPTION_TEXT); + } + const many = unknownKeyMessage(SelectOptionSchema, { ...OPTION, disabled: true, readonly: true, editable: false }); + expect(many.split(OPTION_TEXT)).toHaveLength(2); + for (const key of ['disabled', 'readonly', 'editable']) expect(many).toContain(`\`${key}\``); + }); + + it('the history sentence still comes last', () => { + const m = unknownKeyMessage(SelectOptionSchema, { ...OPTION, disabled: true }); + expect(m.indexOf(OPTION_TEXT)).toBeLessThan(m.indexOf('Until #4001 closed this shape')); + }); + + it('it is NOT the containers\' prescription — an option has no fields inside it', () => { + // The wrong turn this card was warned about: reusing #8199's text would + // tell an option author to "write `readonly` on the form field(s) inside + // it", and an option has no inside. The two texts must not cross over in + // either direction. + const option = unknownKeyMessage(SelectOptionSchema, { ...OPTION, disabled: true }); + expect(option).not.toContain('Editability is a FIELD-level concern'); + expect(option).not.toContain('the form field(s) inside'); + for (const [name, schema, base] of VISIBILITY_ONLY) { + expect( + unknownKeyMessage(schema, { ...base, disabled: true }), + `${name} picked up the option prescription`, + ).not.toContain(OPTION_TEXT); + } + }); + + it('it points at per-option `visibleWhen`, and that predicate really parses HERE', () => { + // Prose must not claim what the schema does not honour. Both readings the + // prescription advertises are parsed back: a record-dependent predicate and + // the `current_user` one that only this surface binds (ADR-0068). + const m = unknownKeyMessage(SelectOptionSchema, { ...OPTION, disabled: true }); + expect(m).toContain('per-option `visibleWhen`'); + expect(m).toContain('`current_user` (ADR-0068)'); + expect(SelectOptionSchema.safeParse({ ...OPTION, visibleWhen: 'record.country == "cn"' }).success).toBe(true); + expect(SelectOptionSchema.safeParse({ ...OPTION, visibleWhen: '"admin" in current_user.positions' }).success).toBe(true); + }); + + it('…and at the FIELD-level keys for the whole picker, which really parse THERE', () => { + const m = unknownKeyMessage(SelectOptionSchema, { ...OPTION, disabled: true }); + expect(m).toContain('`readonly: true`'); + expect(m).toContain('`readonlyWhen`'); + expect(FieldSchema.safeParse({ ...FIELD, readonly: true }).success).toBe(true); + expect(FieldSchema.safeParse({ ...FIELD, readonlyWhen: 'record.locked' }).success).toBe(true); + }); + + it('never names `disabledWhen` in the prescription — no field surface declares it', () => { + const m = unknownKeyMessage(SelectOptionSchema, { ...OPTION, disabledWhen: 'record.locked' }); + const prescription = m.slice(m.indexOf('\n • ')); + expect(prescription).not.toContain('disabledWhen'); + }); + + it('says what is true TODAY and leaves the decision open', () => { + // Triage left real product pull for non-selectable field options open as a + // maintainer decision that would widen the accepted set. The prescription + // must not read as "never" — it names the route (a spec decision) instead + // of closing it, and this pin fails a rewrite that hardens it into a + // permanent refusal. + const m = unknownKeyMessage(SelectOptionSchema, { ...OPTION, disabled: true }); + expect(m).toContain('spec decision to ask for'); + expect(m).not.toMatch(/\bnever be\b|\bcannot ever\b/); + }); + + it('no alias row was registered for the family, and the existing renames still fire', () => { + // Same red line as the mother ruling: an alias names a key the shape must + // then accept. And the new set must not shadow the rename channel this + // table already had — a set match `continue`s past it, so a member that + // collided with an alias key would silently delete a working pointer. + SelectOptionSchema.safeParse(OPTION); + const decl = strictObjectDeclarations().find((d) => d.options.surface === 'this select option'); + expect(decl, '`SelectOptionSchema` did not register a declaration').toBeDefined(); + expect(decl!.options.guidanceSets?.map((s) => s.name)) + .toContain('SELECT_OPTION_EDITABILITY_BOUNDARY_KEYS'); + for (const key of EDITABILITY_KEYS) { + expect(decl!.options.aliases?.[key], `\`${key}\` must not have an alias row`).toBeUndefined(); + expect(keySetMatches(SELECT_OPTION_EDITABILITY_GUIDANCE, key)).toBe(true); + } + for (const alias of Object.keys(decl!.options.aliases ?? {})) { + expect( + keySetMatches(SELECT_OPTION_EDITABILITY_GUIDANCE, alias), + `the boundary set swallows the \`${alias}\` alias before its rename can fire`, + ).toBe(false); + } + expect(unknownKeyMessage(SelectOptionSchema, { ...OPTION, visible: true })) + .toContain('Did you mean `visible` → `visibleWhen`?'); + expect(unknownKeyMessage(SelectOptionSchema, { ...OPTION, colour: 'red' })) + .toContain('Did you mean `colour` → `color`?'); + }); + + it('no acceptance change on the option — rejected stays rejected, accepted stays identical', () => { + for (const key of EDITABILITY_KEYS) { + expect( + SelectOptionSchema.safeParse({ ...OPTION, [key]: true }).success, + `\`${key}\` must stay rejected — this card curates messages, it does not widen the shape`, + ).toBe(false); + } + const authored = { + label: 'In Progress', + value: 'in_progress', + color: '#ff0000', + default: true, + visibleWhen: 'record.country == "cn"', + }; + const r = SelectOptionSchema.safeParse(authored); + expect(r.success).toBe(true); + const parsed = r.data as { label: string; value: string; color?: string; default?: boolean; visibleWhen?: { source?: string } }; + expect(Object.keys(parsed).sort()).toEqual(['color', 'default', 'label', 'value', 'visibleWhen']); + expect(parsed.visibleWhen?.source).toBe('record.country == "cn"'); + }); + + it('an option key in no family at all still gets the bare message', () => { + const m = unknownKeyMessage(SelectOptionSchema, { ...OPTION, 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 index 3f7229aa39..897451210e 100644 --- a/packages/spec/src/shared/editability-boundary.ts +++ b/packages/spec/src/shared/editability-boundary.ts @@ -1,7 +1,8 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * # The section / page-component **editability boundary** (#7887) + * # The **editability boundary** on the shapes that have no editability (#7887, + * #8201) * * Maintainer ruling, 2026-08-12: `FormSectionSchema` (`ui/view.zod.ts`) and * `PageComponentSchema` (`ui/page.zod.ts`) gate **visibility only**; editability @@ -13,6 +14,37 @@ * What the ruling *does* buy the author is this module: the rejection stops * being bare and starts naming where the key belongs. * + * ## The third shape — `SelectOptionSchema` (#8201) + * + * `data/field.zod.ts`'s `SelectOptionSchema` was outside the ruling's scope, so + * #8199 left it bare while its two siblings gained the prescription. It has the + * ruling now, on a **re-measured** version of the ruling's own premise rather + * than by analogy — the premise that carries it is "no editability semantics + * exist here to enforce", and for an option that is a fact about the renderer, + * not an argument. Measured on objectui `origin/main` @ `aca27fa`: the + * object-field pipeline these options actually feed has **zero** per-option + * `disabled` consumers — `packages/fields/src/widgets/SelectField.tsx:161` + * calls the root-level `disabled` "the single authority" in its own comment, + * and `RadioField.tsx:124` reads only `props.disabled`. A shown-but-unselectable + * option does exist in objectui's **SDUI** family + * (`packages/components/src/renderers/form/select.tsx:62`), but on that + * package's own select-option vocabulary in `packages/types` — a different + * shape from this one, so it makes no key here enforced. + * + * The prescription it gets is NOT the siblings' text, because the siblings' + * destination does not exist here: a section's answer is "write `readonly` on + * the fields inside", and an option has nothing inside it. Its answer is to + * withdraw the option itself, which per-option `visibleWhen` really does + * (ADR-0068 binds `current_user` on that surface, and the rule validator + * refuses a write of a value whose predicate is false) — see + * {@link SELECT_OPTION_EDITABILITY_GUIDANCE}. + * + * **What is true today, not forever.** Triage left real product pull for + * non-selectable field options open as a maintainer decision that would widen + * the accepted set. Both prescriptions below say what the platform reads today + * and why; neither claims the answer can never change, and the way to change it + * is a spec decision, not a key an author writes. + * * ## 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 @@ -34,11 +66,17 @@ 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). + * Every spelling here is rejected by `FormSectionSchema`, `PageComponentSchema` + * and — since #8201 — `SelectOptionSchema` 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). + * + * One list, two prescriptions ({@link EDITABILITY_BOUNDARY_GUIDANCE} for the + * containers, {@link SELECT_OPTION_EDITABILITY_GUIDANCE} for an option): the + * vocabulary an author reaches for is the same everywhere, so a spelling added + * here is answered on every shape that has no editability semantics, while the + * answer stays specific to the shape that gives it. */ const EDITABILITY_BOUNDARY_KEYS = [ 'disabled', @@ -70,10 +108,54 @@ const EDITABILITY_BOUNDARY_GUIDANCE: KeySetGuidance = { + 'hide the whole section or component, use `visibleWhen`.', }; +/** + * The same ruling on `SelectOptionSchema`, with the destination that is true for + * an OPTION rather than the one that is true for a container (#8201). + * + * Shares {@link EDITABILITY_BOUNDARY_KEYS} — one vocabulary, so a spelling + * added to the family is answered on every shape that has no editability — and + * nothing else: the prescription is written per-shape because the *answer* is + * per-shape. A section's is "put the key on the fields inside"; an option has + * no inside, so its answer is to withdraw the option. + * + * Filed on the option's own table (`data/field.zod.ts`) rather than shared with + * the two view/page shapes for the placement reason #8199 established: a shared + * table may carry only what is true of all its consumers, and this text is true + * of exactly one of them. It cannot collide with that table's rename channel + * either — `SelectOptionSchema`'s aliases answer `text` / `name` / `title` / + * `key` / `id` / `isDefault` / `selected` / `colour` / `visible` / `showWhen`, + * and no member of this set is among them, so no alias row is consumed + * (`alias-integrity.test.ts`'s #7889 reachability check). + */ +export const SELECT_OPTION_EDITABILITY_GUIDANCE: KeySetGuidance = { + name: 'SELECT_OPTION_EDITABILITY_BOUNDARY_KEYS', + keys: EDITABILITY_BOUNDARY_KEYS, + prescription: + 'Editability is not a per-OPTION concern — a deliberate boundary, not a missing ' + + 'key (#8201): an option declares WHICH value may be picked and WHEN it is offered, ' + + 'and nothing in the field pipeline reads a per-option enabled/disabled flag today ' + + '(the select and radio widgets treat the FIELD-level state as the single ' + + 'authority), so a key here would be metadata the renderer never honours ' + + '(ADR-0049). To withdraw ONE option, give it the per-option `visibleWhen` ' + + 'predicate — the one `*When` surface that also binds `current_user` (ADR-0068), ' + + 'so an option can be withheld per record or per role, and the rule validator ' + + 'refuses a write of a value whose predicate is false. To freeze the WHOLE picker, ' + + 'write `readonly: true` (or the conditional `readonlyWhen` predicate) on the field ' + + 'that owns these options. If a shown-but-unselectable option ever earns a reader, ' + + 'that is a spec decision to ask for — not a key to write here.', +}; + /** * {@link VISIBILITY_STRICT_OPTIONS} for the two shapes that gate visibility and * **nothing else** — `FormSectionSchema` and `PageComponentSchema`. * + * ⚠️ Carries the family's `surface` string, which both consumers **override** + * with their own name (`'this form section'` / `'this page component'`, #8202): + * this table is shared by two shapes, so it can no more carry one shape's name + * than the table above it can carry one shape's prescription. Pinned in + * `editability-boundary.test.ts` so a third consumer cannot inherit the family + * string in silence. + * * ## Why the boundary prescription is filed HERE and not in the shared table * * `VISIBILITY_STRICT_OPTIONS` has **three** consumers, and the third — diff --git a/packages/spec/src/shared/visibility.ts b/packages/spec/src/shared/visibility.ts index 17648e698e..300b85f5ea 100644 --- a/packages/spec/src/shared/visibility.ts +++ b/packages/spec/src/shared/visibility.ts @@ -117,6 +117,27 @@ const VISIBILITY_KEY_PATTERN = /vis|conceal|hidden|show.?when/i; * What changed in the bytes is that the prescription is now rendered as the * template's `\n • ` bullet rather than joined inline with a space, which is * how every other closed surface in this package already reads it. + * + * ## `surface` here is a FAMILY default that every consumer overrides (#8202) + * + * The three shapes on this table each name themselves — `'this form field'`, + * `'this form section'`, `'this page component'` — by spreading these options + * and setting `surface` at their own call site. The string below is what they + * shared until #8202, and it is kept only as the family's name: a table shared + * by three shapes cannot carry one shape's name, which is the same placement + * rule #8199 drew for the boundary prescription, read from the `surface` end. + * + * Why the shapes stopped sharing it: while all three answered a key identically + * the shared string cost nothing. Since #8199 they do not — on a field + * `disabled` gets a rename pointer toward `readonly`, on a section or component + * it gets the editability-boundary prescription telling the author to move the + * key to the fields inside. Those two answers contradict each other by design, + * and the contradiction only reads correctly if the message says which shape + * the author is on. + * + * A consumer that forgets to override inherits this string silently, so the + * inheritance is pinned rather than trusted: `editability-boundary.test.ts` + * asserts no live declaration on this family still reports it. */ export const VISIBILITY_STRICT_OPTIONS: StrictObjectOptions = { surface: 'this view/page schema', 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 6bd493e224..1e2c33cffd 100644 --- a/packages/spec/src/shared/visible-when-alias-guidance.test.ts +++ b/packages/spec/src/shared/visible-when-alias-guidance.test.ts @@ -184,7 +184,13 @@ describe('#7832 — the deliberate gaps (an alias here would name a key the shap // 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. + // + // `SelectOptionSchema` was out of that ruling's scope until #8201, which + // re-measured the ruling's premise on the object-field pipeline (zero + // per-option `disabled` consumers) and gave this shape the boundary too — + // with its own prescription, because an option has no fields inside it to + // be redirected to. Same channel, same red line: still no key, still no + // alias row, so all three rows below stay green by construction. 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 d280c255f5..99c9ed0be0 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -120,7 +120,17 @@ export const ElementDataSourceSchema = lazySchema(() => strictObject({ * PageComponentSchema.properties}, which is that widget's own contract * (`component.zod.ts`) and not this shape's. */ -export const PageComponentSchema = lazySchema(() => strictObject(VISIBILITY_ONLY_STRICT_OPTIONS, { +export const PageComponentSchema = lazySchema(() => strictObject({ + ...VISIBILITY_ONLY_STRICT_OPTIONS, + // #8202 — the shape names ITSELF rather than inheriting the shared + // `'this view/page schema'`. Since #7887 a `disabled` written here gets the + // editability-boundary prescription while the same key on a form field gets + // a rename pointer toward `readonly`; the two answers contradict each other + // by design, so the message has to say which shape refused the key. Filed + // per-shape because a table shared by three shapes cannot carry one shape's + // name (#8199's placement rule). + surface: 'this page component', +}, { /** Definition */ type: z.union([ PageComponentType, diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index a64cb46d3d..ce11f0f9b0 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -3138,8 +3138,13 @@ describe('visibility unknown-key message order — fix before history (#6416 / # it('names the wrong key first, then the alias pointer, then the history', () => { const m = messageFor({ visibleWhenn: 'record.a == 1' }); - // 1. which key is wrong — and nothing before it - expect(m.startsWith('Unrecognized key(s) on this view/page schema: `visibleWhenn`.')).toBe(true); + // 1. which key is wrong — and nothing before it. + // The surface NAME moved with #8202 and the ORDER pin did not: this probe + // is `FormFieldSchema`, which since #8202 names itself instead of + // sharing `'this view/page schema'` with the section and the page + // component. What is asserted here is unchanged — the key statement + // comes first and nothing precedes it. + expect(m.startsWith('Unrecognized key(s) on this form field: `visibleWhenn`.')).toBe(true); // 2. the fix, immediately after it — this is the whole point of the reorder. // Since #6619 the prescription arrives as the shared template's ` • ` // bullet (it rides the set-keyed guidance channel); the position is @@ -3163,10 +3168,16 @@ describe('visibility unknown-key message order — fix before history (#6416 / # // No prescription branch — the sentence follows the key statement directly, // exactly as it always did. Full-message pin, so a stray separator or a // duplicated clause fails here. + // + // The surface reads `this form section` since #8202: this probe is a + // SECTION, and the three shapes that shared one string now each name + // themselves. Note that the pin above, on a form FIELD, reads a different + // string — the two literals disagreeing is #8202's whole content, and if a + // future edit makes them agree again, one of these two is now wrong. const res = FormSectionSchema.safeParse({ label: 'S', fields: [], bogusKey: true }); expect(res.success).toBe(false); const m = res.error!.issues.find((i) => i.code === 'unrecognized_keys')!.message; - expect(m).toBe(`Unrecognized key(s) on this view/page schema: \`bogusKey\`. ${HISTORY}`); + expect(m).toBe(`Unrecognized key(s) on this form section: \`bogusKey\`. ${HISTORY}`); }); it('emits the history exactly once, whatever the key count', () => { diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 026aa35813..ea47825881 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -1786,6 +1786,16 @@ const FormFieldBaseSchema = lazySchema(() => { return z.object(shape, { error: strictObjectError({ ...VISIBILITY_STRICT_OPTIONS, + // #8202 — this shape names ITSELF. The shared table's `'this view/page + // schema'` was harmless while all three of its consumers answered a key + // the same way; since #7887 they answer `disabled` in two contradictory + // ways on purpose (the rename below on a field, the boundary + // prescription on a section / page component), and the contradiction is + // only coherent if the message says which shape refused the key. A + // per-shape string is by definition not something a table shared by + // three shapes can carry, so it is filed here — the same placement rule + // #8199 drew for the prescription itself. + surface: 'this form field', extraKeys: ['fields'], // The one member of the `visibleWhen` family that can answer `disabled` // with a key of its own (#7832). `VISIBILITY_STRICT_OPTIONS` is shared @@ -1900,7 +1910,15 @@ export const FormFieldSchema: z.ZodType = lazySchema( * 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, { +export const FormSectionSchema = lazySchema(() => strictObject({ + ...VISIBILITY_ONLY_STRICT_OPTIONS, + // #8202 — see the note at `FormFieldBaseSchema`'s options: the boundary + // prescription this table carries tells the author to move an editability + // key to "the form field(s) inside", and the field's own message tells them + // to rename it in place. The reader can only tell which answer is theirs if + // the rejection names the shape, so each of the three shapes names itself. + surface: 'this form section', +}, { /** * Stable identifier for translation lookup. snake_case convention. * When provided, translation bundles can target this section's `label` From 99b63c348d26a6155ecb6a6c3272377a2f300b79 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 13 Aug 2026 06:23:40 +0000 Subject: [PATCH 2/2] =?UTF-8?q?chore(changeset):=20patch=20=E2=80=94=20per?= =?UTF-8?q?-shape=20surface=20strings=20+=20select-option=20editability=20?= =?UTF-8?q?boundary=20(#8202,=20#8201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reachability measured rather than inherited: both halves are parse-reachable error strings, #8201's paragraph also reaches `dist/**/*.d.ts` hover, and the generated reference page is measured NOT to render schema-level JSDoc. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016YBUGvukaeVu9DjKdsHJa9 --- .changeset/per-shape-guidance-completion.md | 70 +++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .changeset/per-shape-guidance-completion.md diff --git a/.changeset/per-shape-guidance-completion.md b/.changeset/per-shape-guidance-completion.md new file mode 100644 index 0000000000..895f1c6d51 --- /dev/null +++ b/.changeset/per-shape-guidance-completion.md @@ -0,0 +1,70 @@ +--- +"@objectstack/spec": patch +--- + +docs(spec): a rejection says which shape refused the key, and a select option gets the editability boundary (#8202, #8201) + +Two text-face halves finishing the story PR #8199 started. No key is added +anywhere, every spelling rejected before is rejected after, and everything that +parsed before parses identically — only the sentence an author reads moves. + +## #8202 — per-shape `surface` strings + +`FormFieldSchema`, `FormSectionSchema` and `PageComponentSchema` shared one +surface string, so every rejection opened `Unrecognized key(s) on this view/page +schema`. Harmless while the three answered a key identically; since #8199 they +do not: + +- on a **field**, `disabled` gets a rename pointer — *Did you mean `disabled` → + `readonly`?* +- on a **section** or **page component**, it gets the editability-boundary + prescription — *write `readonly` on the form field(s) inside instead.* + +Those two answers contradict each other by design, and the contradiction only +reads correctly if the message says which shape the author is on. It now does: +`this form field` / `this form section` / `this page component`. Per #8199's +placement rule the strings are filed at the three call sites rather than in the +shared options table — a table shared by three shapes can no more carry one +shape's name than it can carry one shape's prescription. The shared table keeps +the family name, and no live declaration may still report it +(`alias-integrity.test.ts`). + +## #8201 — `SelectOptionSchema` inherits the #7887 ruling + +The maintainer ruling of 2026-08-12 (a form section / page component gates +visibility only; editability lives on fields) was scoped to two shapes, and +#8199 left the third — a select option — with a bare rejection. It has the +boundary now, on the ruling's own premise re-measured for this shape rather than +by analogy: on objectui `origin/main` @ `aca27fa` the object-field pipeline these +options feed has **zero** per-option `disabled` consumers (`SelectField.tsx:161` +calls the root-level `disabled` "the single authority"; `RadioField.tsx:124` +reads only `props.disabled`). The shown-but-unselectable option that does exist +lives in objectui's SDUI vocabulary, which is not this shape. + +Its prescription is **not** the siblings' text, because the siblings' +destination does not exist here — a section redirects to the fields inside, and +an option has no inside. Writing `disabled` on an option now points at the two +things that are real: per-option `visibleWhen` to withdraw that one option (the +only `*When` surface that binds `current_user`, ADR-0068 — and the rule +validator refuses a write of a value whose predicate is false), and +`readonly` / `readonlyWhen` on the **field** to freeze the whole picker. It +states what the platform honours today; a non-selectable field option remains a +spec decision someone may ask for, not a key an author writes. + +## Why `patch`, measured rather than inherited + +The criterion is whether the prose reaches a consumer: + +- **Parse-reachable error string — YES.** Both halves are `unrecognized_keys` + message text an author reads out of a failed parse, and every claim is pinned + against a real `safeParse` error rather than against the options table. +- **`dist/**/*.d.ts` hover — YES for #8201.** The boundary paragraph is JSDoc on + the exported `SelectOptionSchema` const. +- **Generated reference page — NO, measured.** `content/docs/references/data/` + `field.mdx` is built from the file-level doc block plus per-property + `.describe()` text; a schema's own JSDoc is not rendered (grepping the page + for `SelectOptionSchema`'s existing docblock text returns nothing, while its + per-key `.describe()` string hits). No `.describe()` was touched, so the page + and `authorable-surface.base.json` do not move. + +One reachable consumer is enough and no public export is added, so `patch`.