Skip to content

Commit a18abf3

Browse files
os-zhuangclaude
andauthored
docs(spec): a form section / page component gates visibility only — say so, and tell disabled where it belongs (#7887) (#8199)
* docs(spec): rule the section/component editability boundary and say it in the rejection (#7887) `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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016YBUGvukaeVu9DjKdsHJa9 * refactor(spec): keep the editability-boundary options package-internal (#7887) `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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016YBUGvukaeVu9DjKdsHJa9 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a5dcb74 commit a18abf3

6 files changed

Lines changed: 476 additions & 7 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): a form section / page component gates visibility only — say so, and tell `disabled` where it belongs (#7887)
6+
7+
`FormSectionSchema` and `PageComponentSchema` declare no `disabled`, `readonly`
8+
or `readonlyWhen` slot. Writing one has always been a loud parse error, but a
9+
**bare** one: the message named the offending key and offered nothing, because
10+
there is nothing on those shapes to point a rename at. An author — increasingly
11+
an AI one — had no way to tell "this key is mis-spelled" from "this key belongs
12+
somewhere else entirely".
13+
14+
It is the second. Ruled a **boundary, not a gap** (maintainer, 2026-08-12):
15+
sections and page components gate *visibility*; **editability lives on fields**.
16+
Neither shape has read-only semantics of its own for anything to enforce, so a
17+
slot here would be declared-but-unenforced from the day it landed — the ADR-0049
18+
class this repo is retiring elsewhere.
19+
20+
So no key was added and no alias row was registered. What changed is the
21+
sentence the rejection carries. `disabled`, `disabledWhen`, `readonly`,
22+
`readOnly`, `readonlyWhen` and `editable` on either shape now answer with the
23+
boundary and the destination:
24+
25+
> Editability is a FIELD-level concern. This shape gates VISIBILITY only — a
26+
> deliberate boundary, not a missing key (#7887): a section / page component has
27+
> no read-only semantics of its own to enforce. Write `readonly: true` (or the
28+
> conditional `readonlyWhen` predicate) on the form field(s) inside it instead;
29+
> to hide the whole section or component, use `visibleWhen`.
30+
31+
It points at **`readonlyWhen`** and never at `disabledWhen`, which exists on no
32+
field surface: `field.zod.ts` renames `disabled` to `readonly` for exactly that
33+
reason.
34+
35+
**Acceptance is unchanged, in both directions.** Every metadata document that
36+
parsed before parses identically, and every key rejected before is still
37+
rejected — a guidance string is not an accepted key, and the pins assert both.
38+
The package's public API surface does not move either: the new options table
39+
lives in `shared/editability-boundary.ts`, which the barrel deliberately does not
40+
re-export, alongside the `strictObject` machinery it belongs to.
41+
42+
**The prescription is filed on those two shapes, not on the table they share.**
43+
`VISIBILITY_STRICT_OPTIONS` has a third consumer, `FormFieldSchema`, which is
44+
the one view/page shape that *does* answer `disabled` — through its own
45+
`disabled → readonly` rename. A guidance set consumes a key before the rename
46+
channel is ever consulted, so filing this family in the shared table would have
47+
replaced the family's one correct pointer with a redirect away from it. Section
48+
and component take a new `VISIBILITY_ONLY_STRICT_OPTIONS`; the field shape keeps
49+
the bare options and its message is byte-for-byte what it was.
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #7887 — the section / page-component **editability boundary**, asserted.
5+
*
6+
* The maintainer's ruling of 2026-08-12, operative sentence: *"`FormSectionSchema`
7+
* / `PageComponentSchema` gate **visibility only**; editability lives on fields.
8+
* No `disabled` / `readonly` / `disabledWhen` slot is added to those shapes, and
9+
* no alias row is registered for them."* The deliverable is therefore text-face
10+
* only: the accepted set does not move, the rejected set does not move, and the
11+
* single thing that changes is the sentence an author reads when they write an
12+
* editability key on a shape that has no editability semantics.
13+
*
14+
* ## What each section below would catch
15+
*
16+
* 1. **the prescription reaches a real author** — asserted on the actual
17+
* `unrecognized_keys` message from `safeParse`, never on the options table.
18+
* A row filed in a table nothing consults is exactly the dead-entry shape
19+
* `alias-integrity.test.ts` exists for; reading the message back is the only
20+
* assertion that cannot pass that way.
21+
* 2. **it points at `readonlyWhen`, and never at `disabledWhen`** — `field.zod.ts`
22+
* renames `disabled → readonly` and records that "a field has `readonlyWhen`,
23+
* not `disabledWhen`" (#7832). A prescription naming `disabledWhen` would send
24+
* the author to a key that exists on no field surface, which is worse than the
25+
* bare rejection it replaced.
26+
* 3. **the field surface is untouched** — the trap this card was tiered up for.
27+
* `VISIBILITY_STRICT_OPTIONS` is shared with `FormFieldSchema`, which answers
28+
* `disabled` through its OWN alias row. A guidanceSet match `continue`s past
29+
* the rename channel, so filing this family in the shared table would have
30+
* replaced the one correct pointer in the family with a redirect away from it.
31+
* 4. **acceptance is byte-identical** — the lane's admission criterion. A
32+
* guidance string must never become an accepted key.
33+
*
34+
* The options table itself lives in `editability-boundary.ts`, which the
35+
* `shared/index.ts` barrel deliberately does not re-export — so the package's
36+
* public API surface does not move either (`check:api-surface`), which is the
37+
* same claim one level out.
38+
*/
39+
40+
import { describe, it, expect } from 'vitest';
41+
42+
import { FormFieldSchema, FormSectionSchema } from '../ui/view.zod';
43+
import { PageComponentSchema } from '../ui/page.zod';
44+
import { VISIBILITY_ONLY_STRICT_OPTIONS } from './editability-boundary';
45+
import { keySetMatches } from './suggestions.zod';
46+
47+
/**
48+
* The `unrecognized_keys` message for `value`, or a loud failure.
49+
*
50+
* Same helper, same reasoning, as `visible-when-alias-guidance.test.ts`: the
51+
* probe bodies are minimal and may also miss a required key, so a whole-error
52+
* stringify could let an assertion pass on text from an unrelated issue.
53+
*/
54+
function unknownKeyMessage(
55+
schema: { safeParse: (v: unknown) => { success: boolean; error?: unknown } },
56+
value: unknown,
57+
): string {
58+
const r = schema.safeParse(value);
59+
expect(r.success, `expected REJECTION, got a successful parse of ${JSON.stringify(value)}`).toBe(false);
60+
const issues = (r.error as { issues?: Array<{ code?: string; message?: string }> }).issues ?? [];
61+
const hit = issues.find((i) => i.code === 'unrecognized_keys');
62+
expect(hit, `no \`unrecognized_keys\` issue in ${JSON.stringify(issues)}`).toBeDefined();
63+
return hit?.message ?? '';
64+
}
65+
66+
/** Minimal bodies that reach each surface's unknown-key path. */
67+
const SECTION = { fields: [] } as const;
68+
const COMPONENT = { type: 'text' } as const;
69+
const FORM_FIELD = { field: 'probe' } as const;
70+
71+
/** The two shapes the ruling names, and nothing else. */
72+
const VISIBILITY_ONLY: ReadonlyArray<[string, { safeParse: (v: unknown) => { success: boolean; error?: unknown } }, object]> = [
73+
['FormSectionSchema', FormSectionSchema, SECTION],
74+
['PageComponentSchema', PageComponentSchema, COMPONENT],
75+
];
76+
77+
/** The spellings the boundary set answers. */
78+
const EDITABILITY_KEYS = ['disabled', 'disabledWhen', 'readonly', 'readOnly', 'readonlyWhen', 'editable'] as const;
79+
80+
// ===========================================================================
81+
// 1. The guidance reaches an author — on the real parse error
82+
// ===========================================================================
83+
describe('#7887 — the boundary prescription an author actually sees', () => {
84+
it.each(VISIBILITY_ONLY)('%s answers `disabled` with the boundary, not a bare refusal', (_n, schema, base) => {
85+
const m = unknownKeyMessage(schema, { ...base, disabled: true });
86+
expect(m).toContain('Editability is a FIELD-level concern');
87+
expect(m).toContain('gates VISIBILITY only');
88+
// Rendered through the shared template's prescription channel — the bullet
89+
// is what puts it directly after the key statement and before the history
90+
// sentence (#5955's ordering, pinned for this family in `ui/view.test.ts`).
91+
expect(m).toContain('\n • Editability is a FIELD-level concern');
92+
});
93+
94+
it.each(VISIBILITY_ONLY)('%s answers the whole editability family, not just `disabled`', (_n, schema, base) => {
95+
for (const key of EDITABILITY_KEYS) {
96+
expect(
97+
unknownKeyMessage(schema, { ...base, [key]: 'x' }),
98+
`\`${key}\` should reach the boundary prescription`,
99+
).toContain('Editability is a FIELD-level concern');
100+
}
101+
});
102+
103+
it.each(VISIBILITY_ONLY)('%s emits the prescription ONCE for a body carrying several of them', (_n, schema, base) => {
104+
// The property that makes this a SET rather than N exact entries: one
105+
// paragraph per message, however many members were written.
106+
const m = unknownKeyMessage(schema, { ...base, disabled: true, readonly: true, editable: false });
107+
expect(m.split('Editability is a FIELD-level concern')).toHaveLength(2);
108+
// …and every offending key is still named.
109+
for (const key of ['disabled', 'readonly', 'editable']) expect(m).toContain(`\`${key}\``);
110+
});
111+
112+
it.each(VISIBILITY_ONLY)('%s still puts the history sentence last (the #5955 order survives the new set)', (_n, schema, base) => {
113+
const m = unknownKeyMessage(schema, { ...base, disabled: true });
114+
const history = 'Before ADR-0089 D3a these were dropped silently';
115+
expect(m.indexOf('Editability is a FIELD-level concern')).toBeLessThan(m.indexOf(history));
116+
});
117+
});
118+
119+
// ===========================================================================
120+
// 2. It names `readonlyWhen` — and must never name `disabledWhen`
121+
// ===========================================================================
122+
describe('#7887 — the prescription points at a key that exists', () => {
123+
it.each(VISIBILITY_ONLY)('%s names the field-level `readonly` / `readonlyWhen` pair', (_n, schema, base) => {
124+
const m = unknownKeyMessage(schema, { ...base, disabled: true });
125+
expect(m).toContain('`readonly: true`');
126+
expect(m).toContain('`readonlyWhen`');
127+
});
128+
129+
it.each(VISIBILITY_ONLY)('%s never names `disabledWhen` — no field surface declares it', (_n, schema, base) => {
130+
// `field.zod.ts` renames `disabled → readonly` precisely because a field
131+
// has `readonlyWhen`, not `disabledWhen` (#7832). Pointing at the latter
132+
// would be a rejection that hands the author their next rejection.
133+
const m = unknownKeyMessage(schema, { ...base, disabledWhen: 'record.locked' });
134+
expect(m).toContain('Editability is a FIELD-level concern');
135+
// The offending key is echoed back in the front matter, so the prohibition
136+
// is on the PRESCRIPTION text, which is everything after the bullet.
137+
const prescription = m.slice(m.indexOf('\n • '));
138+
expect(prescription).not.toContain('disabledWhen');
139+
});
140+
141+
it('the boundary also names the visibility escape hatch, and that key really is accepted', () => {
142+
const m = unknownKeyMessage(FormSectionSchema, { ...SECTION, disabled: true });
143+
expect(m).toContain('`visibleWhen`');
144+
expect(FormSectionSchema.safeParse({ ...SECTION, visibleWhen: 'record.x' }).success).toBe(true);
145+
expect(PageComponentSchema.safeParse({ ...COMPONENT, visibleWhen: 'record.x' }).success).toBe(true);
146+
});
147+
});
148+
149+
// ===========================================================================
150+
// 3. The field surface is UNCHANGED — the shared-table trap
151+
// ===========================================================================
152+
describe('#7887 — `FormFieldSchema` sees exactly what it saw before', () => {
153+
it('`disabled` on a form field still renames onto `readonly`, with no boundary text', () => {
154+
const m = unknownKeyMessage(FormFieldSchema, { ...FORM_FIELD, disabled: true });
155+
expect(m).toContain('Did you mean `disabled` → `readonly`?');
156+
// The regression this whole file exists to catch. Hoisting
157+
// `EDITABILITY_BOUNDARY_KEYS` into `VISIBILITY_STRICT_OPTIONS` makes the set
158+
// fire here, and a set match `continue`s past the rename channel — so the
159+
// line above would vanish and this line would appear, redirecting a field
160+
// author AWAY from the one surface where `readonly` is real.
161+
expect(m).not.toContain('Editability is a FIELD-level concern');
162+
});
163+
164+
it('the boundary options are filed on the two visibility-only shapes, never the shared table', () => {
165+
const names = (VISIBILITY_ONLY_STRICT_OPTIONS.guidanceSets ?? []).map((s) => s.name);
166+
expect(names).toContain('EDITABILITY_BOUNDARY_KEYS');
167+
// The ADR-0089 set is still there and still first — the boundary set is an
168+
// addition, not a replacement.
169+
expect(names[0]).toBe('VISIBILITY_KEY_PATTERN');
170+
});
171+
172+
it('no editability key matches `VISIBILITY_KEY_PATTERN`, so set order is not load-bearing', () => {
173+
// Both sets live on one table. If a future edit widened the visibility
174+
// pattern to reach (say) `editable`, declaration order would silently start
175+
// deciding which prescription an author reads — the tie-break
176+
// `alias-integrity.test.ts` forbids any in-repo table from depending on.
177+
const visibility = (VISIBILITY_ONLY_STRICT_OPTIONS.guidanceSets ?? [])
178+
.find((s) => s.name === 'VISIBILITY_KEY_PATTERN');
179+
expect(visibility).toBeDefined();
180+
for (const key of EDITABILITY_KEYS) {
181+
expect(keySetMatches(visibility!, key), `\`${key}\` is claimed by both sets`).toBe(false);
182+
}
183+
});
184+
185+
it('no alias row was registered for the boundary — the ruling forbids one', () => {
186+
// "no alias row is registered for them — an alias would declare a key the
187+
// runtime does not honour". An alias TARGET must be a key the shape accepts
188+
// (`alias-integrity.test.ts`), and neither shape accepts any of these, so a
189+
// row here would be a pointer into a second rejection.
190+
for (const table of [VISIBILITY_ONLY_STRICT_OPTIONS]) {
191+
for (const key of EDITABILITY_KEYS) {
192+
expect(table.aliases?.[key], `\`${key}\` must not have an alias row`).toBeUndefined();
193+
}
194+
}
195+
});
196+
});
197+
198+
// ===========================================================================
199+
// 4. Acceptance is byte-identical — a guidance string is not a key
200+
// ===========================================================================
201+
describe('#7887 — no acceptance change', () => {
202+
it.each(VISIBILITY_ONLY)('%s still REJECTS every editability spelling', (_n, schema, base) => {
203+
for (const key of EDITABILITY_KEYS) {
204+
expect(
205+
schema.safeParse({ ...base, [key]: true }).success,
206+
`\`${key}\` must stay rejected — this card curates messages, it does not widen the shape`,
207+
).toBe(false);
208+
}
209+
});
210+
211+
it('a representative section that parsed before still parses, unchanged in output', () => {
212+
const authored = {
213+
name: 'billing',
214+
label: 'Billing',
215+
description: 'Invoicing details',
216+
collapsible: true,
217+
collapsed: false,
218+
columns: 2,
219+
visibleOn: 'record.type == "customer"',
220+
fields: ['amount', { field: 'currency', readonly: true }],
221+
};
222+
const r = FormSectionSchema.safeParse(authored);
223+
expect(r.success).toBe(true);
224+
// The ADR-0089 fold still runs, and the field-level `readonly` inside is
225+
// still the accepted way to say what `disabled` on the section cannot.
226+
// `ExpressionInputSchema` normalizes the authored string to a
227+
// `{ dialect, source }` pair; the fold is about WHICH KEY carries it.
228+
expect((r.data as { visibleWhen?: { source?: string } }).visibleWhen?.source)
229+
.toBe('record.type == "customer"');
230+
expect((r.data as { visibleOn?: unknown }).visibleOn).toBeUndefined();
231+
});
232+
233+
it('a representative page component that parsed before still parses, unchanged in output', () => {
234+
const authored = {
235+
type: 'record:form',
236+
id: 'main_form',
237+
label: 'Details',
238+
properties: { columns: 2 },
239+
className: 'p-4',
240+
visibility: 'current_user.is_admin',
241+
};
242+
const r = PageComponentSchema.safeParse(authored);
243+
expect(r.success).toBe(true);
244+
expect((r.data as { visibleWhen?: { source?: string } }).visibleWhen?.source)
245+
.toBe('current_user.is_admin');
246+
expect((r.data as { visibility?: unknown }).visibility).toBeUndefined();
247+
});
248+
249+
it.each(VISIBILITY_ONLY)('%s keeps its bare message for a key in no family at all', (_n, schema, base) => {
250+
// The new set must claim the editability family and nothing beyond it: an
251+
// unrelated typo still gets the front matter plus history and no bullet.
252+
const m = unknownKeyMessage(schema, { ...base, totallyUnrelatedKey: true });
253+
expect(m).toContain('`totallyUnrelatedKey`');
254+
expect(m).not.toContain(' • ');
255+
});
256+
});

0 commit comments

Comments
 (0)