Skip to content

Commit cb43296

Browse files
os-zhuangos-zhuangclaude
authored
docs(spec): each shape names itself in its rejection, and a select option gets the editability boundary (#8202, #8201) (#8339)
* 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016YBUGvukaeVu9DjKdsHJa9 * chore(changeset): patch — per-shape surface strings + select-option editability boundary (#8202, #8201) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016YBUGvukaeVu9DjKdsHJa9 --------- Co-authored-by: os-zhuang <support@objectstack.ai> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 5905d7f commit cb43296

10 files changed

Lines changed: 517 additions & 17 deletions
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): a rejection says which shape refused the key, and a select option gets the editability boundary (#8202, #8201)
6+
7+
Two text-face halves finishing the story PR #8199 started. No key is added
8+
anywhere, every spelling rejected before is rejected after, and everything that
9+
parsed before parses identically — only the sentence an author reads moves.
10+
11+
## #8202 — per-shape `surface` strings
12+
13+
`FormFieldSchema`, `FormSectionSchema` and `PageComponentSchema` shared one
14+
surface string, so every rejection opened `Unrecognized key(s) on this view/page
15+
schema`. Harmless while the three answered a key identically; since #8199 they
16+
do not:
17+
18+
- on a **field**, `disabled` gets a rename pointer — *Did you mean `disabled`
19+
`readonly`?*
20+
- on a **section** or **page component**, it gets the editability-boundary
21+
prescription — *write `readonly` on the form field(s) inside instead.*
22+
23+
Those two answers contradict each other by design, and the contradiction only
24+
reads correctly if the message says which shape the author is on. It now does:
25+
`this form field` / `this form section` / `this page component`. Per #8199's
26+
placement rule the strings are filed at the three call sites rather than in the
27+
shared options table — a table shared by three shapes can no more carry one
28+
shape's name than it can carry one shape's prescription. The shared table keeps
29+
the family name, and no live declaration may still report it
30+
(`alias-integrity.test.ts`).
31+
32+
## #8201`SelectOptionSchema` inherits the #7887 ruling
33+
34+
The maintainer ruling of 2026-08-12 (a form section / page component gates
35+
visibility only; editability lives on fields) was scoped to two shapes, and
36+
#8199 left the third — a select option — with a bare rejection. It has the
37+
boundary now, on the ruling's own premise re-measured for this shape rather than
38+
by analogy: on objectui `origin/main` @ `aca27fa` the object-field pipeline these
39+
options feed has **zero** per-option `disabled` consumers (`SelectField.tsx:161`
40+
calls the root-level `disabled` "the single authority"; `RadioField.tsx:124`
41+
reads only `props.disabled`). The shown-but-unselectable option that does exist
42+
lives in objectui's SDUI vocabulary, which is not this shape.
43+
44+
Its prescription is **not** the siblings' text, because the siblings'
45+
destination does not exist here — a section redirects to the fields inside, and
46+
an option has no inside. Writing `disabled` on an option now points at the two
47+
things that are real: per-option `visibleWhen` to withdraw that one option (the
48+
only `*When` surface that binds `current_user`, ADR-0068 — and the rule
49+
validator refuses a write of a value whose predicate is false), and
50+
`readonly` / `readonlyWhen` on the **field** to freeze the whole picker. It
51+
states what the platform honours today; a non-selectable field option remains a
52+
spec decision someone may ask for, not a key an author writes.
53+
54+
## Why `patch`, measured rather than inherited
55+
56+
The criterion is whether the prose reaches a consumer:
57+
58+
- **Parse-reachable error string — YES.** Both halves are `unrecognized_keys`
59+
message text an author reads out of a failed parse, and every claim is pinned
60+
against a real `safeParse` error rather than against the options table.
61+
- **`dist/**/*.d.ts` hover — YES for #8201.** The boundary paragraph is JSDoc on
62+
the exported `SelectOptionSchema` const.
63+
- **Generated reference page — NO, measured.** `content/docs/references/data/`
64+
`field.mdx` is built from the file-level doc block plus per-property
65+
`.describe()` text; a schema's own JSDoc is not rendered (grepping the page
66+
for `SelectOptionSchema`'s existing docblock text returns nothing, while its
67+
per-key `.describe()` string hits). No `.describe()` was touched, so the page
68+
and `authorable-surface.base.json` do not move.
69+
70+
One reachable consumer is enough and no public export is added, so `patch`.

packages/spec/src/data/field.zod.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
import { z } from 'zod';
44
import { retiredKey } from '../shared/retired-key';
55
import { strictObject } from '../shared/strict-object';
6+
// Package-internal, like `strict-object` itself — the `shared/index.ts` barrel
7+
// deliberately does not re-export it, so nothing about the public API surface
8+
// moves. No cycle back into this file: that module's only runtime import is
9+
// `shared/visibility.ts`, which imports nothing at runtime.
10+
import { SELECT_OPTION_EDITABILITY_GUIDANCE } from '../shared/editability-boundary';
611
import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
712
import { SystemIdentifierSchema } from '../shared/identifiers.zod';
813
import { ExpressionInputSchema } from '../shared/expression.zod';
@@ -147,10 +152,41 @@ const FIELD_HISTORY =
147152
'Until #4001 closed this shape these were dropped silently — the field was still created, '
148153
+ 'minus whatever the key was meant to constrain, protect or compute.';
149154

155+
/**
156+
* ## An option is offered or withheld — it is never "shown but unselectable"
157+
* (#8201 — boundary, not gap)
158+
*
159+
* There is no `disabled`, `readonly` or `readonlyWhen` on a select option, and
160+
* that is a **deliberate boundary** rather than a slot nobody added. It is the
161+
* 2026-08-12 #7887 ruling reaching its third shape, on that ruling's own
162+
* premise re-measured for this one: nothing in the object-field pipeline these
163+
* options feed reads a per-option enabled/disabled flag — objectui's select and
164+
* radio widgets treat the FIELD-level state as the single authority — so
165+
* declaring one here would ship the ADR-0049 declared-but-unenforced shape.
166+
* (A shown-but-unselectable option does exist in objectui's SDUI component
167+
* family, but on that package's own option vocabulary, not this shape.)
168+
*
169+
* Writing one anyway stays a loud parse error — unchanged — and since #8201
170+
* that error carries {@link SELECT_OPTION_EDITABILITY_GUIDANCE}, which points
171+
* at the two things that are real: {@link SelectOptionSchema.visibleWhen} to
172+
* withdraw THIS option (per record or, uniquely on this surface, per
173+
* `current_user` — ADR-0068), and `readonly` / `readonlyWhen` on the FIELD to
174+
* freeze the whole picker.
175+
*
176+
* If a non-selectable field option ever earns a real reader, that is a spec
177+
* decision that widens the accepted set — this boundary records what the
178+
* platform honours today, not a claim that the answer can never change.
179+
*/
150180
export const SelectOptionSchema = lazySchema(() => strictObject({
151181
surface: 'this select option',
152182
history: FIELD_HISTORY,
153183
aliases: { text: 'label', name: 'label', title: 'label', key: 'value', id: 'value', isDefault: 'default', selected: 'default', colour: 'color', visible: 'visibleWhen', showWhen: 'visibleWhen' },
184+
// #8201. No alias row for the editability family, per the same red line the
185+
// mother ruling drew: an alias names a key the shape must then accept, and
186+
// this shape accepts none of them. The set consumes those spellings before
187+
// the rename channel runs, and none of the alias keys above is a member, so
188+
// no existing pointer is shadowed (`alias-integrity.test.ts`, #7889).
189+
guidanceSets: [SELECT_OPTION_EDITABILITY_GUIDANCE],
154190
}, {
155191
label: z.string().describe('Display label (human-readable, any case allowed)'),
156192
value: SystemIdentifierSchema.describe('Stored value (lowercase machine identifier)'),

packages/spec/src/shared/alias-integrity.test.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -971,10 +971,18 @@ describe('alias integrity — every table is a true claim about its schema', ()
971971
// re-opening the blind spot.
972972
const bySurface = new Map(SURFACES.map((s) => [s.options.surface, s]));
973973

974-
const visibility = [...SURFACES].filter((s) => s.options.surface === 'this view/page schema');
975-
// Three call sites share the options: FormFieldBase (via strictObjectError),
976-
// FormSection, PageComponent — distinct shapes, so distinct declarations.
974+
// Three call sites share the OPTIONS; since #8202 they no longer share the
975+
// `surface` string, so the selector names the three shapes instead of the
976+
// one string they used to report. Same claim as before — those three
977+
// declarations exist and each carries the ADR-0089 set — and a strictly
978+
// stronger selector: it fails if a shape stops declaring through the
979+
// folded table AND if one of them silently loses its own name.
980+
const VISIBILITY_FAMILY = ['this form field', 'this form section', 'this page component'];
981+
const visibility = [...SURFACES].filter((s) => VISIBILITY_FAMILY.includes(s.options.surface));
982+
// FormFieldBase (via strictObjectError), FormSection, PageComponent —
983+
// distinct shapes, so distinct declarations.
977984
expect(visibility.length).toBeGreaterThanOrEqual(3);
985+
expect(new Set(visibility.map((s) => s.options.surface)).size).toBe(3);
978986
for (const v of visibility) {
979987
expect(v.options.guidanceSets?.map((g) => g.name)).toContain('VISIBILITY_KEY_PATTERN');
980988
}
@@ -992,6 +1000,26 @@ describe('alias integrity — every table is a true claim about its schema', ()
9921000
expect(Object.keys(tenancy!.options.guidance ?? {}).sort()).toEqual(['crossTenantAccess', 'strategy']);
9931001
});
9941002

1003+
it('no live surface still reports the shared view/page FAMILY name (#8202)', () => {
1004+
// `VISIBILITY_STRICT_OPTIONS.surface` is the family's name, and every
1005+
// consumer overrides it with its own shape's (`'this form field'` /
1006+
// `'this form section'` / `'this page component'`) — because since #7887
1007+
// the shapes answer the same key in two contradictory ways, and the answer
1008+
// is only readable if the message says which shape it came from.
1009+
//
1010+
// A fourth consumer that spreads the shared options and forgets the
1011+
// override inherits the family string silently: the message goes vague
1012+
// rather than wrong, so nothing else fails. This walk sees every table the
1013+
// package constructs, which makes it the one place that can notice.
1014+
const inherited = SURFACES
1015+
.filter((s) => s.options.surface === 'this view/page schema')
1016+
.map((s) => `${s.options.surface} (keys: ${Object.keys(s.shape).slice(0, 4).join(', ')}…)`);
1017+
expect(
1018+
inherited,
1019+
'these declarations spread the shared visibility options without naming their own shape',
1020+
).toEqual([]);
1021+
});
1022+
9951023
it('the two maps #6619 MISSED are folded and judged here too (#6805)', () => {
9961024
// #6619's inventory was two short, and both survivors were the same shape
9971025
// as the three above — `unrecognized_keys` prescription tables attached to

0 commit comments

Comments
 (0)